@chrischall/office-outlook-mcp 0.3.5 → 0.4.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/README.md +10 -2
- package/dist/bundle.js +326 -17
- package/dist/client.js +13 -0
- package/dist/tools/calendar.js +164 -2
- package/dist/tools/writes.js +222 -14
- package/dist/version.js +1 -1
- package/dist/view.js +5 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Outlook / Microsoft 365 — mail, calendar, contacts and tasks via natural language",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.4.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Outlook (Microsoft 365)",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for Outlook / Microsoft 365 — read mail, folders, calendar, contacts and tasks, and send mail with confirmation",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "office-outlook-mcp",
|
|
3
3
|
"displayName": "Outlook (Microsoft 365)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Outlook / Microsoft 365 for Claude — read mail, folders, calendar, contacts and tasks, and send mail with confirmation, using your signed-in browser session",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Hall",
|
package/README.md
CHANGED
|
@@ -108,13 +108,21 @@ Everything is optional — with nothing set, the server captures from the browse
|
|
|
108
108
|
|
|
109
109
|
**Read** — `outlook_list_folders`, `outlook_list_messages`,
|
|
110
110
|
`outlook_get_message`, `outlook_list_attachments`, `outlook_list_events`,
|
|
111
|
-
`outlook_get_event`, `outlook_list_calendars`, `
|
|
111
|
+
`outlook_get_event`, `outlook_list_calendars`, `outlook_find_meeting_times`,
|
|
112
|
+
`outlook_get_schedule`, `outlook_get_profile`,
|
|
112
113
|
`outlook_get_mailbox_settings`, `outlook_list_contacts`, `outlook_list_people`,
|
|
113
114
|
`outlook_list_tasks`
|
|
114
115
|
|
|
115
116
|
**Write** (all ask you to confirm first — see [Confirmations](#confirmations)) — `outlook_send_mail`,
|
|
116
117
|
`outlook_create_draft`, `outlook_mark_read`, `outlook_move_message`,
|
|
117
|
-
`outlook_create_event`
|
|
118
|
+
`outlook_create_event`, `outlook_update_event`
|
|
119
|
+
|
|
120
|
+
**Meetings** — `outlook_find_meeting_times` asks Outlook's Scheduling
|
|
121
|
+
Assistant for slots when everyone is free, and `outlook_get_schedule` shows
|
|
122
|
+
each person's busy blocks. `outlook_create_event` and `outlook_update_event`
|
|
123
|
+
attach a Microsoft Teams meeting by default and return its join link; pass
|
|
124
|
+
`teamsMeeting: false` to leave it off. Only the organizer can update a
|
|
125
|
+
meeting.
|
|
118
126
|
|
|
119
127
|
**Diagnostics** — `outlook_healthcheck`
|
|
120
128
|
|
package/dist/bundle.js
CHANGED
|
@@ -23737,10 +23737,10 @@ function scanXMcpHeaderDeclarations(inputSchema) {
|
|
|
23737
23737
|
if (!RFC9110_TOKEN.test(raw)) return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`;
|
|
23738
23738
|
const type = typeof schema.type === "string" ? schema.type : void 0;
|
|
23739
23739
|
if (type === void 0 || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? "<none>"}`;
|
|
23740
|
-
const
|
|
23741
|
-
const prior = seenLower.get(
|
|
23740
|
+
const lower2 = raw.toLowerCase();
|
|
23741
|
+
const prior = seenLower.get(lower2);
|
|
23742
23742
|
if (prior !== void 0) return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`;
|
|
23743
|
-
seenLower.set(
|
|
23743
|
+
seenLower.set(lower2, raw);
|
|
23744
23744
|
declarations.push({
|
|
23745
23745
|
path,
|
|
23746
23746
|
headerName: raw,
|
|
@@ -37148,7 +37148,7 @@ var init_version = __esm({
|
|
|
37148
37148
|
"src/version.ts"() {
|
|
37149
37149
|
"use strict";
|
|
37150
37150
|
PACKAGE_NAME = "office-outlook-mcp";
|
|
37151
|
-
VERSION = "0.
|
|
37151
|
+
VERSION = "0.4.0";
|
|
37152
37152
|
}
|
|
37153
37153
|
});
|
|
37154
37154
|
|
|
@@ -48387,6 +48387,19 @@ var OutlookClient = class {
|
|
|
48387
48387
|
}
|
|
48388
48388
|
return this.get(target.pathname.slice(prefix.length) + target.search, opts);
|
|
48389
48389
|
}
|
|
48390
|
+
/**
|
|
48391
|
+
* POST a read-only query — `findmeetingtimes`, `getschedule` — that changes
|
|
48392
|
+
* nothing in the mailbox. Kept apart from `write` so a reader can tell the
|
|
48393
|
+
* two apart at the call site, and because these take `Prefer` (notably
|
|
48394
|
+
* `outlook.timezone`, without which every returned slot is UTC).
|
|
48395
|
+
*/
|
|
48396
|
+
async post(path, body, opts = {}) {
|
|
48397
|
+
this.#requireConfigured();
|
|
48398
|
+
return this.#client().fetchJson("POST", path, {
|
|
48399
|
+
body,
|
|
48400
|
+
headers: opts.prefer ? { Prefer: opts.prefer } : void 0
|
|
48401
|
+
});
|
|
48402
|
+
}
|
|
48390
48403
|
/**
|
|
48391
48404
|
* The single write path. Everything mutating goes through here so auth and
|
|
48392
48405
|
* error shaping stay in one place.
|
|
@@ -48487,7 +48500,11 @@ function fullEvent(e) {
|
|
|
48487
48500
|
Attendees: e.Attendees?.map(
|
|
48488
48501
|
(a) => pruned({ Who: addr(a), Response: a.Status?.Response })
|
|
48489
48502
|
),
|
|
48490
|
-
OnlineMeetingUrl
|
|
48503
|
+
// The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
|
|
48504
|
+
// every live Teams meeting checked.
|
|
48505
|
+
JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || void 0,
|
|
48506
|
+
// Kept beside JoinUrl so a consumer of the old field still finds it.
|
|
48507
|
+
OnlineMeetingUrl: e.OnlineMeetingUrl || void 0,
|
|
48491
48508
|
Preview: e.BodyPreview?.trim() || void 0,
|
|
48492
48509
|
WebLink: e.WebLink
|
|
48493
48510
|
});
|
|
@@ -48703,7 +48720,7 @@ async function mailboxTimeZone(client2) {
|
|
|
48703
48720
|
}
|
|
48704
48721
|
|
|
48705
48722
|
// src/tools/calendar.ts
|
|
48706
|
-
var EVENT_SELECT = "Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview";
|
|
48723
|
+
var EVENT_SELECT = "Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,OnlineMeeting,BodyPreview";
|
|
48707
48724
|
function qs2(params) {
|
|
48708
48725
|
const parts = [];
|
|
48709
48726
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -48712,6 +48729,28 @@ function qs2(params) {
|
|
|
48712
48729
|
}
|
|
48713
48730
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
48714
48731
|
}
|
|
48732
|
+
function dateTimeTimeZone(value, zone) {
|
|
48733
|
+
if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
|
|
48734
|
+
const d = new Date(value);
|
|
48735
|
+
if (!Number.isNaN(d.getTime())) {
|
|
48736
|
+
return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ""), TimeZone: "UTC" };
|
|
48737
|
+
}
|
|
48738
|
+
}
|
|
48739
|
+
return { DateTime: value, TimeZone: zone };
|
|
48740
|
+
}
|
|
48741
|
+
function assertWindow(start, end) {
|
|
48742
|
+
const a = Date.parse(start);
|
|
48743
|
+
const b = Date.parse(end);
|
|
48744
|
+
if (!Number.isNaN(a) && !Number.isNaN(b) && b <= a) {
|
|
48745
|
+
throw new McpToolError(`The window ends (${end}) before it starts (${start}).`, {
|
|
48746
|
+
hint: "`end` must be later than `start`."
|
|
48747
|
+
});
|
|
48748
|
+
}
|
|
48749
|
+
}
|
|
48750
|
+
function trimTime(dt) {
|
|
48751
|
+
return dt?.replace(/\.0+$/, "");
|
|
48752
|
+
}
|
|
48753
|
+
var attendeeList = (what) => external_exports.array(external_exports.string().min(3)).describe(`Email addresses of ${what}`);
|
|
48715
48754
|
function registerCalendarTools(server, client2) {
|
|
48716
48755
|
server.registerTool(
|
|
48717
48756
|
"outlook_list_events",
|
|
@@ -48788,6 +48827,119 @@ function registerCalendarTools(server, client2) {
|
|
|
48788
48827
|
return minifiedResult(plainCollection(data));
|
|
48789
48828
|
}
|
|
48790
48829
|
);
|
|
48830
|
+
server.registerTool(
|
|
48831
|
+
"outlook_find_meeting_times",
|
|
48832
|
+
{
|
|
48833
|
+
description: "Find times when people can meet \u2014 Outlook's Scheduling Assistant. Give the attendees, a search window and a duration; Outlook checks everyone's free/busy (and the signed-in user's, as organizer) and returns ranked candidate slots with each attendee's availability. By default only working hours are searched. Use this to answer \"when can X and Y meet?\"; then book with outlook_create_event. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone), and returned slots are in that zone too. " + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
48834
|
+
annotations: { readOnlyHint: true },
|
|
48835
|
+
inputSchema: external_exports.object({
|
|
48836
|
+
view: viewParam(VIEWS2),
|
|
48837
|
+
attendees: attendeeList("required attendees").min(1),
|
|
48838
|
+
optionalAttendees: attendeeList("optional attendees").optional(),
|
|
48839
|
+
start: external_exports.string().min(1).describe("Earliest the meeting may start, e.g. 2026-10-12T09:00:00"),
|
|
48840
|
+
end: external_exports.string().min(1).describe("Latest the meeting may end, e.g. 2026-10-16T17:00:00"),
|
|
48841
|
+
durationMinutes: external_exports.number().int().min(5).max(1440).describe("Meeting length in minutes"),
|
|
48842
|
+
timeZone: external_exports.string().optional().describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
|
|
48843
|
+
maxCandidates: external_exports.number().int().min(1).max(50).optional().describe("Max suggestions (default 10)"),
|
|
48844
|
+
workingHoursOnly: external_exports.boolean().optional().describe("Only suggest times inside working hours (default true). false searches around the clock."),
|
|
48845
|
+
minimumAttendeePercentage: external_exports.number().min(0).max(100).optional().describe("Lowest confidence (0-100) a slot needs to be suggested. Lower it to see slots where not everyone is free."),
|
|
48846
|
+
isOrganizerOptional: external_exports.boolean().optional().describe("true when the signed-in user need not attend, so their own calendar is ignored")
|
|
48847
|
+
})
|
|
48848
|
+
},
|
|
48849
|
+
async (args) => {
|
|
48850
|
+
assertWindow(args.start, args.end);
|
|
48851
|
+
const zone = args.timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
|
|
48852
|
+
const body = {
|
|
48853
|
+
Attendees: [
|
|
48854
|
+
...args.attendees.map((Address) => ({ Type: "Required", EmailAddress: { Address } })),
|
|
48855
|
+
...(args.optionalAttendees ?? []).map((Address) => ({
|
|
48856
|
+
Type: "Optional",
|
|
48857
|
+
EmailAddress: { Address }
|
|
48858
|
+
}))
|
|
48859
|
+
],
|
|
48860
|
+
TimeConstraint: {
|
|
48861
|
+
ActivityDomain: args.workingHoursOnly === false ? "Unrestricted" : "Work",
|
|
48862
|
+
Timeslots: [
|
|
48863
|
+
{ Start: dateTimeTimeZone(args.start, zone), End: dateTimeTimeZone(args.end, zone) }
|
|
48864
|
+
]
|
|
48865
|
+
},
|
|
48866
|
+
MeetingDuration: `PT${args.durationMinutes}M`,
|
|
48867
|
+
MaxCandidates: args.maxCandidates ?? 10,
|
|
48868
|
+
ReturnSuggestionReasons: true,
|
|
48869
|
+
...args.minimumAttendeePercentage !== void 0 ? { MinimumAttendeePercentage: args.minimumAttendeePercentage } : {},
|
|
48870
|
+
...args.isOrganizerOptional !== void 0 ? { IsOrganizerOptional: args.isOrganizerOptional } : {}
|
|
48871
|
+
};
|
|
48872
|
+
const data = await client2.post("/me/findmeetingtimes", body, { prefer: `outlook.timezone="${zone}"` });
|
|
48873
|
+
if (resolveView(args.view, VIEWS2) === "raw") return mailboxUntrusted(data);
|
|
48874
|
+
const suggestions = (data?.MeetingTimeSuggestions ?? []).map((s) => ({
|
|
48875
|
+
Start: trimTime(s.MeetingTimeSlot?.Start?.DateTime),
|
|
48876
|
+
End: trimTime(s.MeetingTimeSlot?.End?.DateTime),
|
|
48877
|
+
Confidence: s.Confidence,
|
|
48878
|
+
Organizer: s.OrganizerAvailability,
|
|
48879
|
+
Attendees: Object.fromEntries(
|
|
48880
|
+
(s.AttendeeAvailability ?? []).map((a) => [
|
|
48881
|
+
a.Attendee?.EmailAddress?.Address ?? a.Attendee?.EmailAddress?.Name ?? "?",
|
|
48882
|
+
a.Availability
|
|
48883
|
+
])
|
|
48884
|
+
),
|
|
48885
|
+
Reason: s.SuggestionReason
|
|
48886
|
+
}));
|
|
48887
|
+
return mailboxUntrusted({
|
|
48888
|
+
timeZone: zone,
|
|
48889
|
+
count: suggestions.length,
|
|
48890
|
+
suggestions,
|
|
48891
|
+
// Outlook explains an empty result ("AttendeesUnavailable", ...); an
|
|
48892
|
+
// empty list without it reads as "nobody is ever free".
|
|
48893
|
+
...data?.EmptySuggestionsReason ? { emptySuggestionsReason: data.EmptySuggestionsReason } : {}
|
|
48894
|
+
});
|
|
48895
|
+
}
|
|
48896
|
+
);
|
|
48897
|
+
server.registerTool(
|
|
48898
|
+
"outlook_get_schedule",
|
|
48899
|
+
{
|
|
48900
|
+
description: "Get people's free/busy blocks for a time window \u2014 the grid view of Outlook's Scheduling Assistant. Returns each person's busy, tentative and out-of-office items (with subject and location where their calendar shares them). Use outlook_find_meeting_times to have Outlook pick slots instead. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone). " + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
48901
|
+
annotations: { readOnlyHint: true },
|
|
48902
|
+
inputSchema: external_exports.object({
|
|
48903
|
+
view: viewParam(VIEWS2),
|
|
48904
|
+
people: attendeeList("people (or rooms) to look up").min(1).max(100),
|
|
48905
|
+
start: external_exports.string().min(1).describe("Window start, e.g. 2026-10-12T09:00:00"),
|
|
48906
|
+
end: external_exports.string().min(1).describe("Window end, e.g. 2026-10-12T17:00:00"),
|
|
48907
|
+
timeZone: external_exports.string().optional().describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
|
|
48908
|
+
intervalMinutes: external_exports.number().int().min(5).max(1440).optional().describe("Slot size for the raw view's AvailabilityView string (default 30)")
|
|
48909
|
+
})
|
|
48910
|
+
},
|
|
48911
|
+
async ({ view, people, start, end, timeZone, intervalMinutes }) => {
|
|
48912
|
+
assertWindow(start, end);
|
|
48913
|
+
const zone = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
|
|
48914
|
+
const data = await client2.post(
|
|
48915
|
+
"/me/calendar/getschedule",
|
|
48916
|
+
{
|
|
48917
|
+
Schedules: people,
|
|
48918
|
+
StartTime: dateTimeTimeZone(start, zone),
|
|
48919
|
+
EndTime: dateTimeTimeZone(end, zone),
|
|
48920
|
+
AvailabilityViewInterval: intervalMinutes ?? 30
|
|
48921
|
+
},
|
|
48922
|
+
{ prefer: `outlook.timezone="${zone}"` }
|
|
48923
|
+
);
|
|
48924
|
+
if (resolveView(view, VIEWS2) === "raw") return mailboxUntrusted(data);
|
|
48925
|
+
const schedules = (data?.value ?? []).map(
|
|
48926
|
+
(p) => (
|
|
48927
|
+
// An unresolvable address must not read as "free all day".
|
|
48928
|
+
p.Error !== void 0 && p.Error !== null ? { Who: p.ScheduleId, Error: p.Error } : {
|
|
48929
|
+
Who: p.ScheduleId,
|
|
48930
|
+
Busy: (p.ScheduleItems ?? []).map((i) => ({
|
|
48931
|
+
Start: trimTime(i.Start?.DateTime),
|
|
48932
|
+
End: trimTime(i.End?.DateTime),
|
|
48933
|
+
Status: i.Status,
|
|
48934
|
+
...i.Subject ? { Subject: i.Subject } : {},
|
|
48935
|
+
...i.Location ? { Location: i.Location } : {}
|
|
48936
|
+
}))
|
|
48937
|
+
}
|
|
48938
|
+
)
|
|
48939
|
+
);
|
|
48940
|
+
return mailboxUntrusted({ timeZone: zone, schedules });
|
|
48941
|
+
}
|
|
48942
|
+
);
|
|
48791
48943
|
}
|
|
48792
48944
|
|
|
48793
48945
|
// src/tools/directory.ts
|
|
@@ -48912,6 +49064,31 @@ function messageBody(subject, body, html) {
|
|
|
48912
49064
|
Body: { ContentType: html === true ? "HTML" : "Text", Content: body }
|
|
48913
49065
|
};
|
|
48914
49066
|
}
|
|
49067
|
+
var TEAMS = { IsOnlineMeeting: true, OnlineMeetingProvider: "TeamsForBusiness" };
|
|
49068
|
+
var teamsMeetingParam = external_exports.boolean().optional().describe("Attach a Microsoft Teams meeting (default true). false leaves it off.");
|
|
49069
|
+
var EVENT_STATE_SELECT = "Id,Subject,IsOrganizer,Start,End,Location,Attendees,IsOnlineMeeting,OnlineMeetingProvider,OnlineMeeting";
|
|
49070
|
+
function attendee(Address, Type) {
|
|
49071
|
+
return { Type, EmailAddress: { Address } };
|
|
49072
|
+
}
|
|
49073
|
+
var lower = (a) => a.EmailAddress?.Address?.toLowerCase();
|
|
49074
|
+
function joinUrlOf(e) {
|
|
49075
|
+
return e?.OnlineMeeting?.JoinUrl || void 0;
|
|
49076
|
+
}
|
|
49077
|
+
function isTeams(e) {
|
|
49078
|
+
return e.IsOnlineMeeting === true && e.OnlineMeetingProvider === "TeamsForBusiness";
|
|
49079
|
+
}
|
|
49080
|
+
function dateTimeTimeZone2(value, zone) {
|
|
49081
|
+
if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
|
|
49082
|
+
const d = new Date(value);
|
|
49083
|
+
if (!Number.isNaN(d.getTime())) {
|
|
49084
|
+
return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ""), TimeZone: "UTC" };
|
|
49085
|
+
}
|
|
49086
|
+
}
|
|
49087
|
+
return { DateTime: value, TimeZone: zone };
|
|
49088
|
+
}
|
|
49089
|
+
var naiveMs = (dt) => Date.parse(`${dt.replace(/\.\d+$/, "")}Z`);
|
|
49090
|
+
var fromNaiveMs = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "");
|
|
49091
|
+
var trimTime2 = (dt) => dt?.replace(/\.\d+$/, "");
|
|
48915
49092
|
function registerWriteTools(server, client2) {
|
|
48916
49093
|
server.registerTool(
|
|
48917
49094
|
"outlook_send_mail",
|
|
@@ -49071,7 +49248,7 @@ function registerWriteTools(server, client2) {
|
|
|
49071
49248
|
server.registerTool(
|
|
49072
49249
|
"outlook_create_event",
|
|
49073
49250
|
{
|
|
49074
|
-
description: 'Create a calendar event. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. ' + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
|
|
49251
|
+
description: 'Create a calendar event or meeting. A Microsoft Teams meeting is attached by default (`teamsMeeting: false` to skip) and its join link is returned. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. To pick a time first, use outlook_find_meeting_times. ' + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
|
|
49075
49252
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
49076
49253
|
inputSchema: external_exports.object({
|
|
49077
49254
|
subject: external_exports.string().describe("Event title"),
|
|
@@ -49080,11 +49257,18 @@ function registerWriteTools(server, client2) {
|
|
|
49080
49257
|
timeZone: external_exports.string().optional().describe("Windows time-zone name. Defaults to the MAILBOX time zone."),
|
|
49081
49258
|
location: external_exports.string().optional().describe("Location display name"),
|
|
49082
49259
|
body: external_exports.string().optional().describe("Event description"),
|
|
49083
|
-
attendees: recipientList,
|
|
49260
|
+
attendees: recipientList.describe("Required attendees' email addresses"),
|
|
49261
|
+
optionalAttendees: recipientList.describe("Optional attendees' email addresses"),
|
|
49262
|
+
teamsMeeting: teamsMeetingParam,
|
|
49084
49263
|
confirmToken: confirmTokenParam
|
|
49085
49264
|
})
|
|
49086
49265
|
},
|
|
49087
|
-
async ({ subject, start, end, timeZone, location, body, attendees, confirmToken }, ctx) => {
|
|
49266
|
+
async ({ subject, start, end, timeZone, location, body, attendees, optionalAttendees, teamsMeeting, confirmToken }, ctx) => {
|
|
49267
|
+
const wantTeams = teamsMeeting !== false;
|
|
49268
|
+
const allAttendees = [
|
|
49269
|
+
...(attendees ?? []).map((a) => attendee(a, "Required")),
|
|
49270
|
+
...(optionalAttendees ?? []).map((a) => attendee(a, "Optional"))
|
|
49271
|
+
];
|
|
49088
49272
|
const tz = timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
|
|
49089
49273
|
const payload = {
|
|
49090
49274
|
Subject: subject,
|
|
@@ -49092,18 +49276,14 @@ function registerWriteTools(server, client2) {
|
|
|
49092
49276
|
End: { DateTime: end, TimeZone: tz },
|
|
49093
49277
|
...location ? { Location: { DisplayName: location } } : {},
|
|
49094
49278
|
...body ? { Body: { ContentType: "Text", Content: body } } : {},
|
|
49095
|
-
...
|
|
49096
|
-
|
|
49097
|
-
EmailAddress: { Address },
|
|
49098
|
-
Type: "Required"
|
|
49099
|
-
}))
|
|
49100
|
-
} : {}
|
|
49279
|
+
...allAttendees.length ? { Attendees: allAttendees } : {},
|
|
49280
|
+
...wantTeams ? TEAMS : {}
|
|
49101
49281
|
};
|
|
49102
49282
|
const gate = await confirmWrite(ctx, {
|
|
49103
49283
|
tool: "outlook_create_event",
|
|
49104
49284
|
action: "calendar.create_event",
|
|
49105
49285
|
message: "Review and confirm this event (attendees are emailed an invitation):",
|
|
49106
|
-
summary: `Create event "${subject}" ${start} to ${end} (${tz})`,
|
|
49286
|
+
summary: `Create ${wantTeams ? "Teams meeting" : "event"} "${subject}" ${start} to ${end} (${tz})`,
|
|
49107
49287
|
// One signed-in mailbox per server process.
|
|
49108
49288
|
account: void 0,
|
|
49109
49289
|
request: { method: "POST", path: "/me/events", body: payload },
|
|
@@ -49115,7 +49295,136 @@ function registerWriteTools(server, client2) {
|
|
|
49115
49295
|
"/me/events",
|
|
49116
49296
|
payload
|
|
49117
49297
|
);
|
|
49118
|
-
|
|
49298
|
+
let joinUrl = joinUrlOf(created);
|
|
49299
|
+
if (wantTeams && !joinUrl && created?.Id) {
|
|
49300
|
+
const after = await client2.get(
|
|
49301
|
+
`/me/events/${encodeURIComponent(created.Id)}?$select=OnlineMeeting`
|
|
49302
|
+
);
|
|
49303
|
+
joinUrl = joinUrlOf(after);
|
|
49304
|
+
}
|
|
49305
|
+
return minifiedResult({
|
|
49306
|
+
created: true,
|
|
49307
|
+
Id: created?.Id,
|
|
49308
|
+
WebLink: created?.WebLink,
|
|
49309
|
+
...joinUrl ? { JoinUrl: joinUrl } : {},
|
|
49310
|
+
...wantTeams && !joinUrl ? { warning: "The event was created, but Outlook returned no Teams join link." } : {}
|
|
49311
|
+
});
|
|
49312
|
+
}
|
|
49313
|
+
);
|
|
49314
|
+
server.registerTool(
|
|
49315
|
+
"outlook_update_event",
|
|
49316
|
+
{
|
|
49317
|
+
description: "Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting's length. A Microsoft Teams meeting is added if the event lacks one (`teamsMeeting: false` to skip). Attendees are sent an updated invitation. Only the organizer can update a meeting \u2014 an attendee's edit would change their own copy alone, so it is refused. The result is verified by re-reading the event. " + CONFIRM_FLOW_SENTENCE + " " + CONFIRM_INJECTION_RULE,
|
|
49318
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
49319
|
+
inputSchema: external_exports.object({
|
|
49320
|
+
id: external_exports.string().min(1).describe("Event Id from outlook_list_events or outlook_get_event"),
|
|
49321
|
+
subject: external_exports.string().optional().describe("New title"),
|
|
49322
|
+
start: external_exports.string().optional().describe("New start, ISO 8601 local time e.g. 2026-09-22T15:00:00"),
|
|
49323
|
+
end: external_exports.string().optional().describe("New end. Omit when moving `start` to keep the current length."),
|
|
49324
|
+
timeZone: external_exports.string().optional().describe("Windows time-zone name for start/end. Defaults to the MAILBOX time zone."),
|
|
49325
|
+
location: external_exports.string().optional().describe("New location display name"),
|
|
49326
|
+
body: external_exports.string().optional().describe("New description (replaces the existing one)"),
|
|
49327
|
+
addAttendees: recipientList.describe("Required attendees to add"),
|
|
49328
|
+
addOptionalAttendees: recipientList.describe("Optional attendees to add"),
|
|
49329
|
+
removeAttendees: recipientList.describe("Attendees to remove"),
|
|
49330
|
+
teamsMeeting: teamsMeetingParam,
|
|
49331
|
+
confirmToken: confirmTokenParam
|
|
49332
|
+
})
|
|
49333
|
+
},
|
|
49334
|
+
async (args, ctx) => {
|
|
49335
|
+
const tz = args.timeZone ?? await mailboxTimeZone(client2) ?? "UTC";
|
|
49336
|
+
const path = `/me/events/${encodeURIComponent(args.id)}`;
|
|
49337
|
+
const readEvent = (zone) => client2.get(`${path}?$select=${EVENT_STATE_SELECT}`, {
|
|
49338
|
+
prefer: `outlook.timezone="${zone}"`
|
|
49339
|
+
});
|
|
49340
|
+
const current = await readEvent(tz);
|
|
49341
|
+
if (current?.IsOrganizer === false) {
|
|
49342
|
+
throw new McpToolError("Only the organizer can update this meeting.", {
|
|
49343
|
+
hint: "You are an attendee. Changing your copy would not reach the organizer or anyone else; ask the organizer, or propose a new time from Outlook."
|
|
49344
|
+
});
|
|
49345
|
+
}
|
|
49346
|
+
const payload = {};
|
|
49347
|
+
if (args.subject !== void 0) payload.Subject = args.subject;
|
|
49348
|
+
if (args.location !== void 0) payload.Location = { DisplayName: args.location };
|
|
49349
|
+
if (args.body !== void 0) payload.Body = { ContentType: "Text", Content: args.body };
|
|
49350
|
+
if (args.start !== void 0 || args.end !== void 0) {
|
|
49351
|
+
const start = args.start !== void 0 ? dateTimeTimeZone2(args.start, tz) : void 0;
|
|
49352
|
+
let end = args.end !== void 0 ? dateTimeTimeZone2(args.end, tz) : void 0;
|
|
49353
|
+
if (start && !end && current?.Start?.DateTime && current?.End?.DateTime) {
|
|
49354
|
+
const length = naiveMs(current.End.DateTime) - naiveMs(current.Start.DateTime);
|
|
49355
|
+
end = { DateTime: fromNaiveMs(naiveMs(start.DateTime) + length), TimeZone: start.TimeZone };
|
|
49356
|
+
}
|
|
49357
|
+
if (args.start !== void 0 && args.end !== void 0 && start?.TimeZone !== end?.TimeZone) {
|
|
49358
|
+
throw new McpToolError("`start` and `end` are in different zones.", {
|
|
49359
|
+
hint: "Give both the same way: both local wall-clock times, or both with Z or an offset."
|
|
49360
|
+
});
|
|
49361
|
+
}
|
|
49362
|
+
const startIn = start ?? (end && end.TimeZone !== tz ? (await readEvent(end.TimeZone))?.Start : current?.Start);
|
|
49363
|
+
if (end && startIn?.DateTime && naiveMs(end.DateTime) <= naiveMs(startIn.DateTime)) {
|
|
49364
|
+
throw new McpToolError("The meeting would end before it starts.", {
|
|
49365
|
+
hint: "`end` must be later than `start`."
|
|
49366
|
+
});
|
|
49367
|
+
}
|
|
49368
|
+
if (start) payload.Start = start;
|
|
49369
|
+
if (end) payload.End = end;
|
|
49370
|
+
}
|
|
49371
|
+
const adding = [
|
|
49372
|
+
...(args.addAttendees ?? []).map((a) => attendee(a, "Required")),
|
|
49373
|
+
...(args.addOptionalAttendees ?? []).map((a) => attendee(a, "Optional"))
|
|
49374
|
+
];
|
|
49375
|
+
const removing = new Set((args.removeAttendees ?? []).map((a) => a.toLowerCase()));
|
|
49376
|
+
if (adding.length || removing.size) {
|
|
49377
|
+
const next = (current?.Attendees ?? []).filter((a) => !removing.has(lower(a) ?? "")).map(({ Type, EmailAddress }) => ({ Type, EmailAddress }));
|
|
49378
|
+
for (const a of adding) {
|
|
49379
|
+
if (!next.some((n) => lower(n) === lower(a))) next.push(a);
|
|
49380
|
+
}
|
|
49381
|
+
payload.Attendees = next;
|
|
49382
|
+
}
|
|
49383
|
+
const addTeams = args.teamsMeeting !== false && !(current && isTeams(current));
|
|
49384
|
+
if (addTeams) Object.assign(payload, TEAMS);
|
|
49385
|
+
if (Object.keys(payload).length === 0) {
|
|
49386
|
+
throw new McpToolError("Nothing to update.", {
|
|
49387
|
+
hint: "Pass at least one of subject, start, end, location, body, or attendee changes."
|
|
49388
|
+
});
|
|
49389
|
+
}
|
|
49390
|
+
const gate = await confirmWrite(ctx, {
|
|
49391
|
+
tool: "outlook_update_event",
|
|
49392
|
+
action: "calendar.update_event",
|
|
49393
|
+
message: "Review and confirm this change (attendees are sent an updated invitation):",
|
|
49394
|
+
summary: `Update "${current?.Subject ?? args.id}": ${Object.keys(payload).join(", ")}`,
|
|
49395
|
+
// One signed-in mailbox per server process.
|
|
49396
|
+
account: void 0,
|
|
49397
|
+
target: args.id,
|
|
49398
|
+
request: { method: "PATCH", path, body: payload },
|
|
49399
|
+
confirmToken: args.confirmToken
|
|
49400
|
+
});
|
|
49401
|
+
if (gate) return gate;
|
|
49402
|
+
await client2.write("PATCH", path, payload);
|
|
49403
|
+
const writtenZone = (payload.Start ?? payload.End)?.TimeZone ?? tz;
|
|
49404
|
+
const after = await readEvent(writtenZone);
|
|
49405
|
+
const unchanged = [];
|
|
49406
|
+
if (payload.Subject !== void 0 && after?.Subject !== payload.Subject) unchanged.push("Subject");
|
|
49407
|
+
for (const key of ["Start", "End"]) {
|
|
49408
|
+
const want = payload[key];
|
|
49409
|
+
if (want && trimTime2(after?.[key]?.DateTime) !== trimTime2(want.DateTime)) unchanged.push(key);
|
|
49410
|
+
}
|
|
49411
|
+
if (payload.Location !== void 0 && after?.Location?.DisplayName !== args.location) {
|
|
49412
|
+
unchanged.push("Location");
|
|
49413
|
+
}
|
|
49414
|
+
if (payload.Attendees !== void 0) {
|
|
49415
|
+
const want = payload.Attendees.map(lower).sort().join();
|
|
49416
|
+
const got = (after?.Attendees ?? []).map(lower).sort().join();
|
|
49417
|
+
if (want !== got) unchanged.push("Attendees");
|
|
49418
|
+
}
|
|
49419
|
+
const joinUrl = joinUrlOf(after);
|
|
49420
|
+
if (addTeams && !joinUrl) unchanged.push("Teams meeting");
|
|
49421
|
+
return minifiedResult({
|
|
49422
|
+
updated: unchanged.length === 0,
|
|
49423
|
+
Id: after?.Id ?? args.id,
|
|
49424
|
+
changed: Object.keys(payload),
|
|
49425
|
+
...joinUrl ? { JoinUrl: joinUrl } : {},
|
|
49426
|
+
...unchanged.length ? { warning: `Outlook accepted the update but these did not change: ${unchanged.join(", ")}.` } : {}
|
|
49427
|
+
});
|
|
49119
49428
|
}
|
|
49120
49429
|
);
|
|
49121
49430
|
}
|
package/dist/client.js
CHANGED
|
@@ -228,6 +228,19 @@ export class OutlookClient {
|
|
|
228
228
|
}
|
|
229
229
|
return this.get(target.pathname.slice(prefix.length) + target.search, opts);
|
|
230
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* POST a read-only query — `findmeetingtimes`, `getschedule` — that changes
|
|
233
|
+
* nothing in the mailbox. Kept apart from `write` so a reader can tell the
|
|
234
|
+
* two apart at the call site, and because these take `Prefer` (notably
|
|
235
|
+
* `outlook.timezone`, without which every returned slot is UTC).
|
|
236
|
+
*/
|
|
237
|
+
async post(path, body, opts = {}) {
|
|
238
|
+
this.#requireConfigured();
|
|
239
|
+
return this.#client().fetchJson('POST', path, {
|
|
240
|
+
body,
|
|
241
|
+
headers: opts.prefer ? { Prefer: opts.prefer } : undefined,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
231
244
|
/**
|
|
232
245
|
* The single write path. Everything mutating goes through here so auth and
|
|
233
246
|
* error shaping stay in one place.
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
2
|
+
import { McpToolError, minifiedResult, resolveView, viewParam } from '@chrischall/mcp-utils';
|
|
3
3
|
import { mailboxUntrusted, UNTRUSTED_DESCRIPTION_SUFFIX } from './_untrusted.js';
|
|
4
4
|
import { compactEvent, fullEvent, projectCollection, } from '../view.js';
|
|
5
5
|
import { fetchPage, nextLinkParam, plainCollection } from './_paging.js';
|
|
6
6
|
import { VIEWS } from './mail.js';
|
|
7
7
|
import { mailboxTimeZone } from '../timezone.js';
|
|
8
|
-
const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,BodyPreview';
|
|
8
|
+
const EVENT_SELECT = 'Id,Subject,Start,End,Location,Organizer,IsAllDay,IsCancelled,ShowAs,OnlineMeetingUrl,OnlineMeeting,BodyPreview';
|
|
9
9
|
function qs(params) {
|
|
10
10
|
const parts = [];
|
|
11
11
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -15,6 +15,37 @@ function qs(params) {
|
|
|
15
15
|
}
|
|
16
16
|
return parts.length ? `?${parts.join('&')}` : '';
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* An Outlook `DateTimeTimeZone` for a caller-supplied time.
|
|
20
|
+
*
|
|
21
|
+
* A wall-clock time ("2026-10-12T09:00:00") is read in `zone`, matching
|
|
22
|
+
* outlook_create_event. One carrying `Z` or an offset already names its
|
|
23
|
+
* instant, so it is converted to UTC rather than having its offset silently
|
|
24
|
+
* ignored and re-read in the mailbox zone.
|
|
25
|
+
*/
|
|
26
|
+
function dateTimeTimeZone(value, zone) {
|
|
27
|
+
if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
|
|
28
|
+
const d = new Date(value);
|
|
29
|
+
if (!Number.isNaN(d.getTime())) {
|
|
30
|
+
return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ''), TimeZone: 'UTC' };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { DateTime: value, TimeZone: zone };
|
|
34
|
+
}
|
|
35
|
+
function assertWindow(start, end) {
|
|
36
|
+
const a = Date.parse(start);
|
|
37
|
+
const b = Date.parse(end);
|
|
38
|
+
if (!Number.isNaN(a) && !Number.isNaN(b) && b <= a) {
|
|
39
|
+
throw new McpToolError(`The window ends (${end}) before it starts (${start}).`, {
|
|
40
|
+
hint: '`end` must be later than `start`.',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Outlook pads times to 7 fractional digits; that is noise to a reader. */
|
|
45
|
+
function trimTime(dt) {
|
|
46
|
+
return dt?.replace(/\.0+$/, '');
|
|
47
|
+
}
|
|
48
|
+
const attendeeList = (what) => z.array(z.string().min(3)).describe(`Email addresses of ${what}`);
|
|
18
49
|
export function registerCalendarTools(server, client) {
|
|
19
50
|
server.registerTool('outlook_list_events', {
|
|
20
51
|
description: 'List calendar events in a date window. This uses the calendar VIEW, which expands recurring series into their individual occurrences — the right tool for "what is on my schedule". Times are returned in `timeZone` when given.' +
|
|
@@ -78,4 +109,135 @@ export function registerCalendarTools(server, client) {
|
|
|
78
109
|
return minifiedResult(data);
|
|
79
110
|
return minifiedResult(plainCollection(data));
|
|
80
111
|
});
|
|
112
|
+
server.registerTool('outlook_find_meeting_times', {
|
|
113
|
+
description: "Find times when people can meet — Outlook's Scheduling Assistant. Give the attendees, a search window and a duration; Outlook checks everyone's free/busy (and the signed-in user's, as organizer) and returns ranked candidate slots with each attendee's availability. By default only working hours are searched. Use this to answer \"when can X and Y meet?\"; then book with outlook_create_event. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone), and returned slots are in that zone too." +
|
|
114
|
+
' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
115
|
+
annotations: { readOnlyHint: true },
|
|
116
|
+
inputSchema: z.object({
|
|
117
|
+
view: viewParam(VIEWS),
|
|
118
|
+
attendees: attendeeList('required attendees').min(1),
|
|
119
|
+
optionalAttendees: attendeeList('optional attendees').optional(),
|
|
120
|
+
start: z.string().min(1).describe('Earliest the meeting may start, e.g. 2026-10-12T09:00:00'),
|
|
121
|
+
end: z.string().min(1).describe('Latest the meeting may end, e.g. 2026-10-16T17:00:00'),
|
|
122
|
+
durationMinutes: z.number().int().min(5).max(1440).describe('Meeting length in minutes'),
|
|
123
|
+
timeZone: z
|
|
124
|
+
.string()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
|
|
127
|
+
maxCandidates: z.number().int().min(1).max(50).optional().describe('Max suggestions (default 10)'),
|
|
128
|
+
workingHoursOnly: z
|
|
129
|
+
.boolean()
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Only suggest times inside working hours (default true). false searches around the clock."),
|
|
132
|
+
minimumAttendeePercentage: z
|
|
133
|
+
.number()
|
|
134
|
+
.min(0)
|
|
135
|
+
.max(100)
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Lowest confidence (0-100) a slot needs to be suggested. Lower it to see slots where not everyone is free.'),
|
|
138
|
+
isOrganizerOptional: z
|
|
139
|
+
.boolean()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe("true when the signed-in user need not attend, so their own calendar is ignored"),
|
|
142
|
+
}),
|
|
143
|
+
}, async (args) => {
|
|
144
|
+
assertWindow(args.start, args.end);
|
|
145
|
+
const zone = args.timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
|
|
146
|
+
const body = {
|
|
147
|
+
Attendees: [
|
|
148
|
+
...args.attendees.map((Address) => ({ Type: 'Required', EmailAddress: { Address } })),
|
|
149
|
+
...(args.optionalAttendees ?? []).map((Address) => ({
|
|
150
|
+
Type: 'Optional',
|
|
151
|
+
EmailAddress: { Address },
|
|
152
|
+
})),
|
|
153
|
+
],
|
|
154
|
+
TimeConstraint: {
|
|
155
|
+
ActivityDomain: args.workingHoursOnly === false ? 'Unrestricted' : 'Work',
|
|
156
|
+
Timeslots: [
|
|
157
|
+
{ Start: dateTimeTimeZone(args.start, zone), End: dateTimeTimeZone(args.end, zone) },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
MeetingDuration: `PT${args.durationMinutes}M`,
|
|
161
|
+
MaxCandidates: args.maxCandidates ?? 10,
|
|
162
|
+
ReturnSuggestionReasons: true,
|
|
163
|
+
...(args.minimumAttendeePercentage !== undefined
|
|
164
|
+
? { MinimumAttendeePercentage: args.minimumAttendeePercentage }
|
|
165
|
+
: {}),
|
|
166
|
+
...(args.isOrganizerOptional !== undefined
|
|
167
|
+
? { IsOrganizerOptional: args.isOrganizerOptional }
|
|
168
|
+
: {}),
|
|
169
|
+
};
|
|
170
|
+
const data = await client.post('/me/findmeetingtimes', body, { prefer: `outlook.timezone="${zone}"` });
|
|
171
|
+
if (resolveView(args.view, VIEWS) === 'raw')
|
|
172
|
+
return mailboxUntrusted(data);
|
|
173
|
+
const suggestions = (data?.MeetingTimeSuggestions ?? []).map((s) => ({
|
|
174
|
+
Start: trimTime(s.MeetingTimeSlot?.Start?.DateTime),
|
|
175
|
+
End: trimTime(s.MeetingTimeSlot?.End?.DateTime),
|
|
176
|
+
Confidence: s.Confidence,
|
|
177
|
+
Organizer: s.OrganizerAvailability,
|
|
178
|
+
Attendees: Object.fromEntries((s.AttendeeAvailability ?? []).map((a) => [
|
|
179
|
+
a.Attendee?.EmailAddress?.Address ?? a.Attendee?.EmailAddress?.Name ?? '?',
|
|
180
|
+
a.Availability,
|
|
181
|
+
])),
|
|
182
|
+
Reason: s.SuggestionReason,
|
|
183
|
+
}));
|
|
184
|
+
return mailboxUntrusted({
|
|
185
|
+
timeZone: zone,
|
|
186
|
+
count: suggestions.length,
|
|
187
|
+
suggestions,
|
|
188
|
+
// Outlook explains an empty result ("AttendeesUnavailable", ...); an
|
|
189
|
+
// empty list without it reads as "nobody is ever free".
|
|
190
|
+
...(data?.EmptySuggestionsReason
|
|
191
|
+
? { emptySuggestionsReason: data.EmptySuggestionsReason }
|
|
192
|
+
: {}),
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
server.registerTool('outlook_get_schedule', {
|
|
196
|
+
description: "Get people's free/busy blocks for a time window — the grid view of Outlook's Scheduling Assistant. Returns each person's busy, tentative and out-of-office items (with subject and location where their calendar shares them). Use outlook_find_meeting_times to have Outlook pick slots instead. `start`/`end` are local wall-clock times in `timeZone` (default: the mailbox zone)." +
|
|
197
|
+
' ' + UNTRUSTED_DESCRIPTION_SUFFIX,
|
|
198
|
+
annotations: { readOnlyHint: true },
|
|
199
|
+
inputSchema: z.object({
|
|
200
|
+
view: viewParam(VIEWS),
|
|
201
|
+
people: attendeeList('people (or rooms) to look up').min(1).max(100),
|
|
202
|
+
start: z.string().min(1).describe('Window start, e.g. 2026-10-12T09:00:00'),
|
|
203
|
+
end: z.string().min(1).describe('Window end, e.g. 2026-10-12T17:00:00'),
|
|
204
|
+
timeZone: z
|
|
205
|
+
.string()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('Windows time-zone name, e.g. "Eastern Standard Time". Defaults to the MAILBOX time zone.'),
|
|
208
|
+
intervalMinutes: z
|
|
209
|
+
.number()
|
|
210
|
+
.int()
|
|
211
|
+
.min(5)
|
|
212
|
+
.max(1440)
|
|
213
|
+
.optional()
|
|
214
|
+
.describe('Slot size for the raw view\'s AvailabilityView string (default 30)'),
|
|
215
|
+
}),
|
|
216
|
+
}, async ({ view, people, start, end, timeZone, intervalMinutes }) => {
|
|
217
|
+
assertWindow(start, end);
|
|
218
|
+
const zone = timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
|
|
219
|
+
const data = await client.post('/me/calendar/getschedule', {
|
|
220
|
+
Schedules: people,
|
|
221
|
+
StartTime: dateTimeTimeZone(start, zone),
|
|
222
|
+
EndTime: dateTimeTimeZone(end, zone),
|
|
223
|
+
AvailabilityViewInterval: intervalMinutes ?? 30,
|
|
224
|
+
}, { prefer: `outlook.timezone="${zone}"` });
|
|
225
|
+
if (resolveView(view, VIEWS) === 'raw')
|
|
226
|
+
return mailboxUntrusted(data);
|
|
227
|
+
const schedules = (data?.value ?? []).map((p) =>
|
|
228
|
+
// An unresolvable address must not read as "free all day".
|
|
229
|
+
p.Error !== undefined && p.Error !== null
|
|
230
|
+
? { Who: p.ScheduleId, Error: p.Error }
|
|
231
|
+
: {
|
|
232
|
+
Who: p.ScheduleId,
|
|
233
|
+
Busy: (p.ScheduleItems ?? []).map((i) => ({
|
|
234
|
+
Start: trimTime(i.Start?.DateTime),
|
|
235
|
+
End: trimTime(i.End?.DateTime),
|
|
236
|
+
Status: i.Status,
|
|
237
|
+
...(i.Subject ? { Subject: i.Subject } : {}),
|
|
238
|
+
...(i.Location ? { Location: i.Location } : {}),
|
|
239
|
+
})),
|
|
240
|
+
});
|
|
241
|
+
return mailboxUntrusted({ timeZone: zone, schedules });
|
|
242
|
+
});
|
|
81
243
|
}
|
package/dist/tools/writes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, minifiedResult, } from '@chrischall/mcp-utils';
|
|
2
|
+
import { CONFIRM_FLOW_SENTENCE, CONFIRM_INJECTION_RULE, confirmTokenParam, confirmWrite, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
|
|
3
3
|
import { mailboxTimeZone } from '../timezone.js';
|
|
4
4
|
const recipientList = z
|
|
5
5
|
.array(z.string().min(3))
|
|
@@ -18,6 +18,44 @@ function messageBody(subject, body, html) {
|
|
|
18
18
|
Body: { ContentType: html === true ? 'HTML' : 'Text', Content: body },
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* What makes an event a Teams meeting. Field names verified on live events
|
|
23
|
+
* 2026-10-08; the join link then appears at `OnlineMeeting.JoinUrl`.
|
|
24
|
+
*/
|
|
25
|
+
const TEAMS = { IsOnlineMeeting: true, OnlineMeetingProvider: 'TeamsForBusiness' };
|
|
26
|
+
const teamsMeetingParam = z
|
|
27
|
+
.boolean()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Attach a Microsoft Teams meeting (default true). false leaves it off.');
|
|
30
|
+
const EVENT_STATE_SELECT = 'Id,Subject,IsOrganizer,Start,End,Location,Attendees,IsOnlineMeeting,OnlineMeetingProvider,OnlineMeeting';
|
|
31
|
+
function attendee(Address, Type) {
|
|
32
|
+
return { Type, EmailAddress: { Address } };
|
|
33
|
+
}
|
|
34
|
+
const lower = (a) => a.EmailAddress?.Address?.toLowerCase();
|
|
35
|
+
function joinUrlOf(e) {
|
|
36
|
+
return e?.OnlineMeeting?.JoinUrl || undefined;
|
|
37
|
+
}
|
|
38
|
+
function isTeams(e) {
|
|
39
|
+
return e.IsOnlineMeeting === true && e.OnlineMeetingProvider === 'TeamsForBusiness';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A `DateTimeTimeZone` for a caller-supplied time: wall-clock is read in
|
|
43
|
+
* `zone`; a value with `Z` or an offset already names its instant and goes to
|
|
44
|
+
* UTC rather than having the offset ignored.
|
|
45
|
+
*/
|
|
46
|
+
function dateTimeTimeZone(value, zone) {
|
|
47
|
+
if (/(Z|[+-]\d{2}:?\d{2})$/i.test(value)) {
|
|
48
|
+
const d = new Date(value);
|
|
49
|
+
if (!Number.isNaN(d.getTime())) {
|
|
50
|
+
return { DateTime: d.toISOString().replace(/\.\d{3}Z$/, ''), TimeZone: 'UTC' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { DateTime: value, TimeZone: zone };
|
|
54
|
+
}
|
|
55
|
+
/** Wall-clock arithmetic: both sides are naive times in one zone. */
|
|
56
|
+
const naiveMs = (dt) => Date.parse(`${dt.replace(/\.\d+$/, '')}Z`);
|
|
57
|
+
const fromNaiveMs = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, '');
|
|
58
|
+
const trimTime = (dt) => dt?.replace(/\.\d+$/, '');
|
|
21
59
|
export function registerWriteTools(server, client) {
|
|
22
60
|
server.registerTool('outlook_send_mail', {
|
|
23
61
|
description: 'Send an email from the signed-in mailbox.' +
|
|
@@ -172,7 +210,7 @@ export function registerWriteTools(server, client) {
|
|
|
172
210
|
});
|
|
173
211
|
});
|
|
174
212
|
server.registerTool('outlook_create_event', {
|
|
175
|
-
description: 'Create a calendar event. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation.' +
|
|
213
|
+
description: 'Create a calendar event or meeting. A Microsoft Teams meeting is attached by default (`teamsMeeting: false` to skip) and its join link is returned. `timeZone` takes a WINDOWS zone name such as "Eastern Standard Time", not an IANA name. Attendees are emailed an invitation. To pick a time first, use outlook_find_meeting_times.' +
|
|
176
214
|
' ' +
|
|
177
215
|
CONFIRM_FLOW_SENTENCE +
|
|
178
216
|
' ' +
|
|
@@ -188,10 +226,17 @@ export function registerWriteTools(server, client) {
|
|
|
188
226
|
.describe('Windows time-zone name. Defaults to the MAILBOX time zone.'),
|
|
189
227
|
location: z.string().optional().describe('Location display name'),
|
|
190
228
|
body: z.string().optional().describe('Event description'),
|
|
191
|
-
attendees: recipientList,
|
|
229
|
+
attendees: recipientList.describe('Required attendees\' email addresses'),
|
|
230
|
+
optionalAttendees: recipientList.describe('Optional attendees\' email addresses'),
|
|
231
|
+
teamsMeeting: teamsMeetingParam,
|
|
192
232
|
confirmToken: confirmTokenParam,
|
|
193
233
|
}),
|
|
194
|
-
}, async ({ subject, start, end, timeZone, location, body, attendees, confirmToken }, ctx) => {
|
|
234
|
+
}, async ({ subject, start, end, timeZone, location, body, attendees, optionalAttendees, teamsMeeting, confirmToken }, ctx) => {
|
|
235
|
+
const wantTeams = teamsMeeting !== false;
|
|
236
|
+
const allAttendees = [
|
|
237
|
+
...(attendees ?? []).map((a) => attendee(a, 'Required')),
|
|
238
|
+
...(optionalAttendees ?? []).map((a) => attendee(a, 'Optional')),
|
|
239
|
+
];
|
|
195
240
|
// Falls back to UTC only when the mailbox itself declares no zone.
|
|
196
241
|
const tz = timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
|
|
197
242
|
const payload = {
|
|
@@ -200,20 +245,14 @@ export function registerWriteTools(server, client) {
|
|
|
200
245
|
End: { DateTime: end, TimeZone: tz },
|
|
201
246
|
...(location ? { Location: { DisplayName: location } } : {}),
|
|
202
247
|
...(body ? { Body: { ContentType: 'Text', Content: body } } : {}),
|
|
203
|
-
...(
|
|
204
|
-
|
|
205
|
-
Attendees: attendees.map((Address) => ({
|
|
206
|
-
EmailAddress: { Address },
|
|
207
|
-
Type: 'Required',
|
|
208
|
-
})),
|
|
209
|
-
}
|
|
210
|
-
: {}),
|
|
248
|
+
...(allAttendees.length ? { Attendees: allAttendees } : {}),
|
|
249
|
+
...(wantTeams ? TEAMS : {}),
|
|
211
250
|
};
|
|
212
251
|
const gate = await confirmWrite(ctx, {
|
|
213
252
|
tool: 'outlook_create_event',
|
|
214
253
|
action: 'calendar.create_event',
|
|
215
254
|
message: 'Review and confirm this event (attendees are emailed an invitation):',
|
|
216
|
-
summary: `Create event "${subject}" ${start} to ${end} (${tz})`,
|
|
255
|
+
summary: `Create ${wantTeams ? 'Teams meeting' : 'event'} "${subject}" ${start} to ${end} (${tz})`,
|
|
217
256
|
// One signed-in mailbox per server process.
|
|
218
257
|
account: undefined,
|
|
219
258
|
request: { method: 'POST', path: '/me/events', body: payload },
|
|
@@ -222,6 +261,175 @@ export function registerWriteTools(server, client) {
|
|
|
222
261
|
if (gate)
|
|
223
262
|
return gate;
|
|
224
263
|
const created = await client.write('POST', '/me/events', payload);
|
|
225
|
-
|
|
264
|
+
let joinUrl = joinUrlOf(created);
|
|
265
|
+
// A 201 is not proof the Teams meeting was provisioned — the link is.
|
|
266
|
+
// Re-read once in case it was filled in after the create returned.
|
|
267
|
+
if (wantTeams && !joinUrl && created?.Id) {
|
|
268
|
+
const after = await client.get(`/me/events/${encodeURIComponent(created.Id)}?$select=OnlineMeeting`);
|
|
269
|
+
joinUrl = joinUrlOf(after);
|
|
270
|
+
}
|
|
271
|
+
return minifiedResult({
|
|
272
|
+
created: true,
|
|
273
|
+
Id: created?.Id,
|
|
274
|
+
WebLink: created?.WebLink,
|
|
275
|
+
...(joinUrl ? { JoinUrl: joinUrl } : {}),
|
|
276
|
+
...(wantTeams && !joinUrl
|
|
277
|
+
? { warning: 'The event was created, but Outlook returned no Teams join link.' }
|
|
278
|
+
: {}),
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
server.registerTool('outlook_update_event', {
|
|
282
|
+
description: 'Update a meeting you organize: retitle, move, relocate, edit the description, or add/remove attendees. Moving only `start` keeps the meeting\'s length. A Microsoft Teams meeting is added if the event lacks one (`teamsMeeting: false` to skip). Attendees are sent an updated invitation. Only the organizer can update a meeting — an attendee\'s edit would change their own copy alone, so it is refused. The result is verified by re-reading the event.' +
|
|
283
|
+
' ' +
|
|
284
|
+
CONFIRM_FLOW_SENTENCE +
|
|
285
|
+
' ' +
|
|
286
|
+
CONFIRM_INJECTION_RULE,
|
|
287
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
288
|
+
inputSchema: z.object({
|
|
289
|
+
id: z.string().min(1).describe('Event Id from outlook_list_events or outlook_get_event'),
|
|
290
|
+
subject: z.string().optional().describe('New title'),
|
|
291
|
+
start: z.string().optional().describe('New start, ISO 8601 local time e.g. 2026-09-22T15:00:00'),
|
|
292
|
+
end: z
|
|
293
|
+
.string()
|
|
294
|
+
.optional()
|
|
295
|
+
.describe('New end. Omit when moving `start` to keep the current length.'),
|
|
296
|
+
timeZone: z
|
|
297
|
+
.string()
|
|
298
|
+
.optional()
|
|
299
|
+
.describe('Windows time-zone name for start/end. Defaults to the MAILBOX time zone.'),
|
|
300
|
+
location: z.string().optional().describe('New location display name'),
|
|
301
|
+
body: z.string().optional().describe('New description (replaces the existing one)'),
|
|
302
|
+
addAttendees: recipientList.describe('Required attendees to add'),
|
|
303
|
+
addOptionalAttendees: recipientList.describe('Optional attendees to add'),
|
|
304
|
+
removeAttendees: recipientList.describe('Attendees to remove'),
|
|
305
|
+
teamsMeeting: teamsMeetingParam,
|
|
306
|
+
confirmToken: confirmTokenParam,
|
|
307
|
+
}),
|
|
308
|
+
}, async (args, ctx) => {
|
|
309
|
+
const tz = args.timeZone ?? (await mailboxTimeZone(client)) ?? 'UTC';
|
|
310
|
+
const path = `/me/events/${encodeURIComponent(args.id)}`;
|
|
311
|
+
const readEvent = (zone) => client.get(`${path}?$select=${EVENT_STATE_SELECT}`, {
|
|
312
|
+
prefer: `outlook.timezone="${zone}"`,
|
|
313
|
+
});
|
|
314
|
+
const current = await readEvent(tz);
|
|
315
|
+
if (current?.IsOrganizer === false) {
|
|
316
|
+
throw new McpToolError('Only the organizer can update this meeting.', {
|
|
317
|
+
hint: "You are an attendee. Changing your copy would not reach the organizer or anyone else; ask the organizer, or propose a new time from Outlook.",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const payload = {};
|
|
321
|
+
if (args.subject !== undefined)
|
|
322
|
+
payload.Subject = args.subject;
|
|
323
|
+
if (args.location !== undefined)
|
|
324
|
+
payload.Location = { DisplayName: args.location };
|
|
325
|
+
if (args.body !== undefined)
|
|
326
|
+
payload.Body = { ContentType: 'Text', Content: args.body };
|
|
327
|
+
if (args.start !== undefined || args.end !== undefined) {
|
|
328
|
+
const start = args.start !== undefined ? dateTimeTimeZone(args.start, tz) : undefined;
|
|
329
|
+
let end = args.end !== undefined ? dateTimeTimeZone(args.end, tz) : undefined;
|
|
330
|
+
// Moving the start alone keeps the length. The current times were read
|
|
331
|
+
// in `tz`, so convert the duration onto whatever zone the new start uses.
|
|
332
|
+
if (start && !end && current?.Start?.DateTime && current?.End?.DateTime) {
|
|
333
|
+
const length = naiveMs(current.End.DateTime) - naiveMs(current.Start.DateTime);
|
|
334
|
+
end = { DateTime: fromNaiveMs(naiveMs(start.DateTime) + length), TimeZone: start.TimeZone };
|
|
335
|
+
}
|
|
336
|
+
// A wall-clock time and one with an offset cannot be ordered without
|
|
337
|
+
// the Windows zone's rules, which we do not have. Refuse rather than
|
|
338
|
+
// skip the order check and write a meeting that ends before it starts.
|
|
339
|
+
if (args.start !== undefined && args.end !== undefined && start?.TimeZone !== end?.TimeZone) {
|
|
340
|
+
throw new McpToolError('`start` and `end` are in different zones.', {
|
|
341
|
+
hint: 'Give both the same way: both local wall-clock times, or both with Z or an offset.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Order check against the start the meeting will have. When only `end`
|
|
345
|
+
// moves that is the current start, read in the end's zone.
|
|
346
|
+
const startIn = start ??
|
|
347
|
+
(end && end.TimeZone !== tz
|
|
348
|
+
? (await readEvent(end.TimeZone))?.Start
|
|
349
|
+
: current?.Start);
|
|
350
|
+
if (end &&
|
|
351
|
+
startIn?.DateTime &&
|
|
352
|
+
naiveMs(end.DateTime) <= naiveMs(startIn.DateTime)) {
|
|
353
|
+
throw new McpToolError('The meeting would end before it starts.', {
|
|
354
|
+
hint: '`end` must be later than `start`.',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (start)
|
|
358
|
+
payload.Start = start;
|
|
359
|
+
if (end)
|
|
360
|
+
payload.End = end;
|
|
361
|
+
}
|
|
362
|
+
const adding = [
|
|
363
|
+
...(args.addAttendees ?? []).map((a) => attendee(a, 'Required')),
|
|
364
|
+
...(args.addOptionalAttendees ?? []).map((a) => attendee(a, 'Optional')),
|
|
365
|
+
];
|
|
366
|
+
const removing = new Set((args.removeAttendees ?? []).map((a) => a.toLowerCase()));
|
|
367
|
+
if (adding.length || removing.size) {
|
|
368
|
+
// PATCH replaces the whole list, so build it from the current one.
|
|
369
|
+
const next = (current?.Attendees ?? [])
|
|
370
|
+
.filter((a) => !removing.has(lower(a) ?? ''))
|
|
371
|
+
.map(({ Type, EmailAddress }) => ({ Type, EmailAddress }));
|
|
372
|
+
for (const a of adding) {
|
|
373
|
+
if (!next.some((n) => lower(n) === lower(a)))
|
|
374
|
+
next.push(a);
|
|
375
|
+
}
|
|
376
|
+
payload.Attendees = next;
|
|
377
|
+
}
|
|
378
|
+
const addTeams = args.teamsMeeting !== false && !(current && isTeams(current));
|
|
379
|
+
if (addTeams)
|
|
380
|
+
Object.assign(payload, TEAMS);
|
|
381
|
+
if (Object.keys(payload).length === 0) {
|
|
382
|
+
throw new McpToolError('Nothing to update.', {
|
|
383
|
+
hint: 'Pass at least one of subject, start, end, location, body, or attendee changes.',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const gate = await confirmWrite(ctx, {
|
|
387
|
+
tool: 'outlook_update_event',
|
|
388
|
+
action: 'calendar.update_event',
|
|
389
|
+
message: 'Review and confirm this change (attendees are sent an updated invitation):',
|
|
390
|
+
summary: `Update "${current?.Subject ?? args.id}": ${Object.keys(payload).join(', ')}`,
|
|
391
|
+
// One signed-in mailbox per server process.
|
|
392
|
+
account: undefined,
|
|
393
|
+
target: args.id,
|
|
394
|
+
request: { method: 'PATCH', path, body: payload },
|
|
395
|
+
confirmToken: args.confirmToken,
|
|
396
|
+
});
|
|
397
|
+
if (gate)
|
|
398
|
+
return gate;
|
|
399
|
+
await client.write('PATCH', path, payload);
|
|
400
|
+
// Re-read rather than trust the status, in the zone the write used so
|
|
401
|
+
// the times compare like for like.
|
|
402
|
+
// Start and End share a zone (enforced above), but either may be alone.
|
|
403
|
+
const writtenZone = (payload.Start ?? payload.End)?.TimeZone ?? tz;
|
|
404
|
+
const after = await readEvent(writtenZone);
|
|
405
|
+
const unchanged = [];
|
|
406
|
+
if (payload.Subject !== undefined && after?.Subject !== payload.Subject)
|
|
407
|
+
unchanged.push('Subject');
|
|
408
|
+
for (const key of ['Start', 'End']) {
|
|
409
|
+
const want = payload[key];
|
|
410
|
+
if (want && trimTime(after?.[key]?.DateTime) !== trimTime(want.DateTime))
|
|
411
|
+
unchanged.push(key);
|
|
412
|
+
}
|
|
413
|
+
if (payload.Location !== undefined && after?.Location?.DisplayName !== args.location) {
|
|
414
|
+
unchanged.push('Location');
|
|
415
|
+
}
|
|
416
|
+
if (payload.Attendees !== undefined) {
|
|
417
|
+
const want = payload.Attendees.map(lower).sort().join();
|
|
418
|
+
const got = (after?.Attendees ?? []).map(lower).sort().join();
|
|
419
|
+
if (want !== got)
|
|
420
|
+
unchanged.push('Attendees');
|
|
421
|
+
}
|
|
422
|
+
const joinUrl = joinUrlOf(after);
|
|
423
|
+
if (addTeams && !joinUrl)
|
|
424
|
+
unchanged.push('Teams meeting');
|
|
425
|
+
return minifiedResult({
|
|
426
|
+
updated: unchanged.length === 0,
|
|
427
|
+
Id: after?.Id ?? args.id,
|
|
428
|
+
changed: Object.keys(payload),
|
|
429
|
+
...(joinUrl ? { JoinUrl: joinUrl } : {}),
|
|
430
|
+
...(unchanged.length
|
|
431
|
+
? { warning: `Outlook accepted the update but these did not change: ${unchanged.join(', ')}.` }
|
|
432
|
+
: {}),
|
|
433
|
+
});
|
|
226
434
|
});
|
|
227
435
|
}
|
package/dist/version.js
CHANGED
package/dist/view.js
CHANGED
|
@@ -70,7 +70,11 @@ export function fullEvent(e) {
|
|
|
70
70
|
...compactEvent(e),
|
|
71
71
|
ShowAs: e.ShowAs,
|
|
72
72
|
Attendees: e.Attendees?.map((a) => pruned({ Who: addr(a), Response: a.Status?.Response })),
|
|
73
|
-
OnlineMeetingUrl
|
|
73
|
+
// The Teams link lives under OnlineMeeting; OnlineMeetingUrl is empty on
|
|
74
|
+
// every live Teams meeting checked.
|
|
75
|
+
JoinUrl: e.OnlineMeeting?.JoinUrl || e.OnlineMeetingUrl || undefined,
|
|
76
|
+
// Kept beside JoinUrl so a consumer of the old field still finds it.
|
|
77
|
+
OnlineMeetingUrl: e.OnlineMeetingUrl || undefined,
|
|
74
78
|
Preview: e.BodyPreview?.trim() || undefined,
|
|
75
79
|
WebLink: e.WebLink,
|
|
76
80
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/office-outlook-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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>",
|
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.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/office-outlook-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|