@juvantlabs/m365-graph-mcp-server 0.1.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/ARCHITECTURE.md +225 -0
- package/CHANGELOG.md +188 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/SECURITY.md +64 -0
- package/dist/auth/confirmation_tokens.d.ts +38 -0
- package/dist/auth/confirmation_tokens.d.ts.map +1 -0
- package/dist/auth/confirmation_tokens.js +85 -0
- package/dist/auth/confirmation_tokens.js.map +1 -0
- package/dist/auth/keyring.d.ts +20 -0
- package/dist/auth/keyring.d.ts.map +1 -0
- package/dist/auth/keyring.js +41 -0
- package/dist/auth/keyring.js.map +1 -0
- package/dist/auth/msal.d.ts +42 -0
- package/dist/auth/msal.d.ts.map +1 -0
- package/dist/auth/msal.js +96 -0
- package/dist/auth/msal.js.map +1 -0
- package/dist/auth/setup.d.ts +18 -0
- package/dist/auth/setup.d.ts.map +1 -0
- package/dist/auth/setup.js +110 -0
- package/dist/auth/setup.js.map +1 -0
- package/dist/client/graph.d.ts +30 -0
- package/dist/client/graph.d.ts.map +1 -0
- package/dist/client/graph.js +38 -0
- package/dist/client/graph.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +131 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/cancel_event.d.ts +18 -0
- package/dist/tools/cancel_event.d.ts.map +1 -0
- package/dist/tools/cancel_event.js +95 -0
- package/dist/tools/cancel_event.js.map +1 -0
- package/dist/tools/copy_file.d.ts +39 -0
- package/dist/tools/copy_file.d.ts.map +1 -0
- package/dist/tools/copy_file.js +168 -0
- package/dist/tools/copy_file.js.map +1 -0
- package/dist/tools/create_event.d.ts +29 -0
- package/dist/tools/create_event.d.ts.map +1 -0
- package/dist/tools/create_event.js +144 -0
- package/dist/tools/create_event.js.map +1 -0
- package/dist/tools/decline_event.d.ts +18 -0
- package/dist/tools/decline_event.d.ts.map +1 -0
- package/dist/tools/decline_event.js +105 -0
- package/dist/tools/decline_event.js.map +1 -0
- package/dist/tools/delete_file.d.ts +28 -0
- package/dist/tools/delete_file.d.ts.map +1 -0
- package/dist/tools/delete_file.js +103 -0
- package/dist/tools/delete_file.js.map +1 -0
- package/dist/tools/download_file.d.ts +43 -0
- package/dist/tools/download_file.d.ts.map +1 -0
- package/dist/tools/download_file.js +133 -0
- package/dist/tools/download_file.js.map +1 -0
- package/dist/tools/get_event.d.ts +27 -0
- package/dist/tools/get_event.d.ts.map +1 -0
- package/dist/tools/get_event.js +55 -0
- package/dist/tools/get_event.js.map +1 -0
- package/dist/tools/index.d.ts +13 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +61 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/list_calendars.d.ts +26 -0
- package/dist/tools/list_calendars.d.ts.map +1 -0
- package/dist/tools/list_calendars.js +60 -0
- package/dist/tools/list_calendars.js.map +1 -0
- package/dist/tools/list_drives.d.ts +27 -0
- package/dist/tools/list_drives.d.ts.map +1 -0
- package/dist/tools/list_drives.js +58 -0
- package/dist/tools/list_drives.js.map +1 -0
- package/dist/tools/list_events.d.ts +51 -0
- package/dist/tools/list_events.d.ts.map +1 -0
- package/dist/tools/list_events.js +119 -0
- package/dist/tools/list_events.js.map +1 -0
- package/dist/tools/list_items.d.ts +31 -0
- package/dist/tools/list_items.d.ts.map +1 -0
- package/dist/tools/list_items.js +81 -0
- package/dist/tools/list_items.js.map +1 -0
- package/dist/tools/move_file.d.ts +18 -0
- package/dist/tools/move_file.d.ts.map +1 -0
- package/dist/tools/move_file.js +60 -0
- package/dist/tools/move_file.js.map +1 -0
- package/dist/tools/search_events.d.ts +25 -0
- package/dist/tools/search_events.d.ts.map +1 -0
- package/dist/tools/search_events.js +71 -0
- package/dist/tools/search_events.js.map +1 -0
- package/dist/tools/search_events_content.d.ts +32 -0
- package/dist/tools/search_events_content.d.ts.map +1 -0
- package/dist/tools/search_events_content.js +106 -0
- package/dist/tools/search_events_content.js.map +1 -0
- package/dist/tools/search_files.d.ts +30 -0
- package/dist/tools/search_files.d.ts.map +1 -0
- package/dist/tools/search_files.js +82 -0
- package/dist/tools/search_files.js.map +1 -0
- package/dist/tools/update_event.d.ts +25 -0
- package/dist/tools/update_event.d.ts.map +1 -0
- package/dist/tools/update_event.js +123 -0
- package/dist/tools/update_event.js.map +1 -0
- package/dist/tools/upload_file.d.ts +38 -0
- package/dist/tools/upload_file.d.ts.map +1 -0
- package/dist/tools/upload_file.js +152 -0
- package/dist/tools/upload_file.js.map +1 -0
- package/dist/types/tool.d.ts +32 -0
- package/dist/types/tool.d.ts.map +1 -0
- package/dist/types/tool.js +10 -0
- package/dist/types/tool.js.map +1 -0
- package/dist/types/validators.d.ts +44 -0
- package/dist/types/validators.d.ts.map +1 -0
- package/dist/types/validators.js +78 -0
- package/dist/types/validators.js.map +1 -0
- package/package.json +72 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Architecture — M365 Graph MCP Server
|
|
2
|
+
|
|
3
|
+
Design rationale for `@juvantlabs/m365-graph-mcp-server`. Read alongside
|
|
4
|
+
the [handbook MCP server spec](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md)
|
|
5
|
+
for the cross-cutting conventions; this doc covers what's specific to
|
|
6
|
+
this server.
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
Wraps the Microsoft Graph API for AI-agent consumption: file operations
|
|
11
|
+
on OneDrive + SharePoint Online, calendar reads + writes on Microsoft
|
|
12
|
+
365 mailboxes. Fulfills the `m365-graph` abstract role per ADR 0002 in
|
|
13
|
+
the handbook; replaces ad-hoc per-instance Graph SDK code with a
|
|
14
|
+
single canonical MCP server, keeping the agent prompt surface clean and
|
|
15
|
+
the auth path consolidated.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
### In scope (planned, via incremental ships)
|
|
20
|
+
|
|
21
|
+
**OneDrive / SharePoint files**
|
|
22
|
+
|
|
23
|
+
- List drives a user has access to.
|
|
24
|
+
- List items in a drive (folders + files), with paging.
|
|
25
|
+
- Search files by name/content within a drive or site.
|
|
26
|
+
- Download a file (streamed; bounded by max-size guard).
|
|
27
|
+
- Upload a small file (single PUT, ≤ 4 MB).
|
|
28
|
+
- Upload a large file (resumable upload session, > 4 MB).
|
|
29
|
+
- Copy / move items (async; tool polls the monitor URL until completion).
|
|
30
|
+
- Delete items (gated by the spec/approval pattern referenced in [`docs/adr/0002-mcp-abstract-roles.md`](https://github.com/juvantlabs/handbook/blob/main/docs/adr/0002-mcp-abstract-roles.md) — destructive ops surface a "spec preview" before executing).
|
|
31
|
+
|
|
32
|
+
**Calendar**
|
|
33
|
+
|
|
34
|
+
- List user calendars.
|
|
35
|
+
- List events in a date range.
|
|
36
|
+
- Create / update / cancel events.
|
|
37
|
+
- Search events by subject / body.
|
|
38
|
+
|
|
39
|
+
### Out of scope
|
|
40
|
+
|
|
41
|
+
- **Mail send** — explicit non-goal for this server. Outbound email is a
|
|
42
|
+
separate concern with its own threat model (SPF / DKIM / DMARC, reply-all
|
|
43
|
+
blast radius, etc.). If needed, ships as `juvantlabs/m365-mail-mcp-server`.
|
|
44
|
+
- **Teams chat / channel posts** — same reasoning; lives in a separate
|
|
45
|
+
server when scoped.
|
|
46
|
+
- **Tenant admin operations** — never automated by the agent layer. These
|
|
47
|
+
remain manual / IT-administered.
|
|
48
|
+
- **General-purpose URL forwarder** — explicitly forbidden by handbook
|
|
49
|
+
spec § Anti-patterns #2. Each tool is typed and schema-validated; the
|
|
50
|
+
Microsoft Graph URL surface is hardcoded inside the tool, not
|
|
51
|
+
caller-supplied.
|
|
52
|
+
|
|
53
|
+
## Authentication
|
|
54
|
+
|
|
55
|
+
OAuth 2.0 via `@azure/msal-node`'s `ConfidentialClientApplication`,
|
|
56
|
+
scoped per the application registration in Microsoft Entra (tenant
|
|
57
|
+
admin grants delegated and/or application permissions to the
|
|
58
|
+
registered app).
|
|
59
|
+
|
|
60
|
+
| Concern | Choice |
|
|
61
|
+
|---|---|
|
|
62
|
+
| OAuth library | `@azure/msal-node` (Microsoft's official) — never roll auth |
|
|
63
|
+
| Flow | Authorization Code with PKCE for delegated; Client Credentials for daemon ops |
|
|
64
|
+
| Scopes | Per-tool minimum: `Files.Read.All` for read-only file tools, `Files.ReadWrite.All` for write tools, `Calendars.Read` / `Calendars.ReadWrite` for calendar tools. Documented in [`README.md`](README.md) § Tools as tools ship. |
|
|
65
|
+
| Token storage | `@napi-rs/keyring` (OS keychain). `keytar` is archived (handbook spec anti-pattern #10) — explicitly NOT used. |
|
|
66
|
+
| Token lifetime | Refresh token rotation handled by MSAL; refreshes never enter the agent's context. |
|
|
67
|
+
| Tenant ID | Validated at startup against the regex `^(common\|organizations\|consumers\|<UUID>)$` (handbook spec § Auth). Prevents arbitrary string interpolation into the authority URL. |
|
|
68
|
+
|
|
69
|
+
The MCP server process loads tokens at startup, refreshes on demand, and
|
|
70
|
+
exits when the client disconnects. **Per-tenant subprocess** — no
|
|
71
|
+
shared module-level state across tenants (handbook spec § Per-tenant
|
|
72
|
+
subprocess).
|
|
73
|
+
|
|
74
|
+
## Threat model
|
|
75
|
+
|
|
76
|
+
This server inherits the 12-item anti-pattern checklist from the
|
|
77
|
+
[ftaricano audit](https://gist.github.com/juvantlabs/a9fe0a76a23b0c1260b1e0ad3194a6da)
|
|
78
|
+
that informs the [handbook MCP server spec](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md)
|
|
79
|
+
§ Anti-patterns. Specific defenses:
|
|
80
|
+
|
|
81
|
+
| Threat | Defense |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Arbitrary local-FS write through `localPath` (audit C1–C4) | Every `download_file` / `upload_file` tool sandboxes to a per-tenant root. `path.resolve` + prefix check + symlink guard. Never trust caller-supplied `localPath`. |
|
|
84
|
+
| URL forwarder primitive (audit C5) | No such tool. Each tool's Graph URL is hardcoded. |
|
|
85
|
+
| Stdout corruption (audit C6) | `console.error` only; `console.log` blocked by ESLint + CI grep. |
|
|
86
|
+
| Outdated MCP SDK (audit C7) | `@modelcontextprotocol/sdk ^1.25.2` pinned in `package.json`. |
|
|
87
|
+
| Vulnerable axios (audit C8) | We use the Microsoft Graph SDK; no direct axios dep. |
|
|
88
|
+
| Vulnerable `jws` transitively (audit C9) | Quarterly `npm audit` (CI step every PR). |
|
|
89
|
+
| Defense-in-depth dead code (audit S1) | CI dead-code grep enforces every exported `validate*` / `sanitize*` / `guard*` is imported elsewhere in `src/`. |
|
|
90
|
+
| README env-var lies (audit S2) | CI README env-var accuracy check. |
|
|
91
|
+
| OData / URL injection (audit S3) | All Graph queries built via the SDK or with explicit `encodeURIComponent`. |
|
|
92
|
+
| Token storage (audit S5) | `@napi-rs/keyring`, never `keytar`. |
|
|
93
|
+
| Whole-file buffering (audit S7) | Downloads stream; max file size capped at 200 MB (configurable). |
|
|
94
|
+
| No async-op polling (audit S8) | `copy` / `move` poll the monitor URL until completion; never return "initiated successfully" as the final result. |
|
|
95
|
+
|
|
96
|
+
### Universal Boundaries (per `SYSTEM_INVARIANTS.md` §4)
|
|
97
|
+
|
|
98
|
+
- No general-purpose URL forwarder primitive.
|
|
99
|
+
- Per-tenant subprocess (no shared cache state across tenants).
|
|
100
|
+
- Stdout discipline: `console.error` only outside protocol path.
|
|
101
|
+
|
|
102
|
+
### Delete-class operations
|
|
103
|
+
|
|
104
|
+
Delete tools (e.g. `delete_file`, `cancel_event`) follow the
|
|
105
|
+
**spec/approval pattern**: the agent submits a spec describing what to
|
|
106
|
+
delete; the tool returns a preview + a `confirmation_token`; a second
|
|
107
|
+
call with the token executes the delete. Mirrors the
|
|
108
|
+
`m365-delete-spec` pattern referenced in FEAT-014 and codified more
|
|
109
|
+
generally in the handbook MCP abstract roles ADR.
|
|
110
|
+
|
|
111
|
+
## Performance characteristics
|
|
112
|
+
|
|
113
|
+
- Typical request latency: 100–500 ms for unary Graph calls; multi-second
|
|
114
|
+
for downloads (streamed).
|
|
115
|
+
- Max file size (download + upload): **200 MB** hard cap, server-side.
|
|
116
|
+
Configurable via `M365_MAX_FILE_SIZE_BYTES` if the deployment needs a
|
|
117
|
+
smaller cap; never larger.
|
|
118
|
+
- Streaming: downloads use the Graph SDK's stream interface; no
|
|
119
|
+
whole-file `arraybuffer` reads (audit S7 mitigation).
|
|
120
|
+
- Async polling: `copy` / `move` ops poll the monitor URL with
|
|
121
|
+
exponential backoff (1s, 2s, 4s, …, max 30s) until status is
|
|
122
|
+
`succeeded` or `failed`. Tool returns the final state, never a 202.
|
|
123
|
+
|
|
124
|
+
## Tool catalog
|
|
125
|
+
|
|
126
|
+
Each row is also reflected in [`README.md`](README.md) § Tools.
|
|
127
|
+
|
|
128
|
+
| Tool | Underlying API call | Input | Output | Scope | Notes |
|
|
129
|
+
|---|---|---|---|---|---|
|
|
130
|
+
| `m365-graph:list_drives` | `GET /me/drive`, `GET /me/drives` | _(none)_ | `{ primary, accessible: [] }` | `Files.Read` | First-ship; validates the auth + Graph plumbing end-to-end. |
|
|
131
|
+
| `m365-graph:list_items` | `GET /me/drive/root/children` or `GET /drives/{id}/items/{item}/children` | `drive_id?`, `item_id?`, `limit?` (1–100, default 50) | `{ count, items: [] }` | `Files.Read` | Item type derived from presence of `folder` facet. `child_count` populated only for folders. |
|
|
132
|
+
| `m365-graph:search_files` | `GET /drives/{id}/root/search(q='…')` (defaults to `/me/drive`) | `query`, `drive_id?`, `limit?` (1–50, default 20) | `{ count, results: [] }` with virtual `path` joining `parentReference.path` + `name` | `Files.Read` | OData function call; query single quotes are escaped (`'` → `''`). |
|
|
133
|
+
| `m365-graph:download_file` | `GET /me/drive/items/{id}` for metadata, then `GET @microsoft.graph.downloadUrl` (Graph CDN) for the bytes | `item_id`, `drive_id?` | `{ local_path, size_bytes, name, content_type }` | `Files.Read` | **Sandboxed**: writes only under `<sandbox>/<tenant>/<sha256(item_id)[:16]>-<sanitized name>`. Streamed via fetch + Node `pipeline`; no whole-file buffering. 200 MB cap, refused pre-fetch via metadata size. Folders rejected. |
|
|
134
|
+
| `m365-graph:list_calendars` | `GET /me/calendars` | `limit?` (1–100, default 50) | `{ count, calendars: [] }` | `Calendars.Read` | Owner extracted from `owner.{name,address}`; falls back to null. |
|
|
135
|
+
| `m365-graph:list_events` | `GET /me/calendarView` (or `/me/calendars/{id}/calendarView`) with `startDateTime` + `endDateTime` query params | `start`, `end` (ISO 8601), `calendar_id?`, `limit?` (1–200, default 100) | `{ window, count, events: [] }` | `Calendars.Read` | **Recurrences expanded** (calendarView vs /events). Ordered by `start/dateTime` ascending. ISO 8601 input lightly validated client-side (regex), Graph does the strict parse. |
|
|
136
|
+
| `m365-graph:search_events` | `GET /me/events?$filter=contains(subject, '…')` | `query`, `limit?` (1–50, default 20) | `{ count, results: [] }` | `Calendars.Read` | Subject-only substring match. `$search` is not supported on the Events resource by Graph; body search would require POST `/search/query` (separate API, deferred). Single-quote escaping (`'` → `''`) on the query. **Series masters**, not expanded occurrences. |
|
|
137
|
+
| `m365-graph:get_event` | `GET /me/events/{id}` | `event_id` | event summary + body + body_truncated + recurrence | `Calendars.Read` | Body capped at 8000 chars (defense-in-depth against pathological event bodies); body_truncated flag indicates clipping. Recurrence rule passed through opaquely (Graph schema). |
|
|
138
|
+
| `m365-graph:upload_file` | `PUT /items/{parent}:/{name}:/content` (≤ 4 MB) or `OneDriveLargeFileUploadTask` (resumable, 10 MB chunks, > 4 MB) | `local_path`, `drive_id?`, `parent_item_id?`, `name?`, `conflict_behavior?` | `{ uploaded: { id, name, size, webUrl, upload_path } }` | `Files.ReadWrite` | Trust note: `local_path` from the agent; the MCP server reads from the user's filesystem (no sandbox on read — would defeat the upload's purpose). 200 MB cap (`checkSizeCap` defense-in-depth). Absolute path logged to stderr. |
|
|
139
|
+
| `m365-graph:create_event` | `POST /me/events` (or `/me/calendars/{id}/events`) | `subject`, `start`, `end` (required); `timezone?`, `body?`, `body_content_type?`, `location?`, `attendees?`, `is_all_day?`, `calendar_id?` | `{ created: <event summary> }` | `Calendars.ReadWrite` | Timezone defaults to UTC (Graph requires explicit TZ). Attendee.type ∈ {required, optional, resource}. Graph sends invites by default. |
|
|
140
|
+
| `m365-graph:update_event` | `PATCH /me/events/{id}` | `event_id` (required); any subset of subject/start/end/timezone/body/location/attendees/is_all_day | `{ updated: <event summary> }` | `Calendars.ReadWrite` | Empty patch rejected. **Attendees REPLACE, not merge** (Graph semantics) — pass the full intended list. Timezone required when start or end is updated (Graph rejects without TZ). |
|
|
141
|
+
| `m365-graph:copy_file` | `POST /items/{id}/copy` (raw response → 202 + Location) → poll monitor URL → `GET resourceLocation` (or fallback list-by-name) | `item_id`, `target_parent_id`; `source_drive_id?`, `target_drive_id?`, `new_name?`, `wait_max_seconds?` | `{ status: "completed", copied: { id, name, size, webUrl, parent_id } }` | `Files.ReadWrite` | **Async polling** with exponential backoff (1s → 2s → 4s → … capped at 30s). Per handbook anti-pattern S8: never returns "initiated successfully" — always polls to terminal state. Fallback list-by-name handles the Graph quirk where completed monitor responses sometimes omit `resourceLocation`. |
|
|
142
|
+
| `m365-graph:move_file` | `PATCH /me/drive/items/{id}` with `{parentReference: {id}, name?}` | `item_id`, `target_parent_id`; `drive_id?`, `new_name?` | `{ moved: { id, name, ... } }` | `Files.ReadWrite` | Synchronous within a single drive. Cross-drive moves are NOT supported by this PATCH (Graph's documented limitation); use copy + delete for those. |
|
|
143
|
+
| `m365-graph:delete_file` | Phase 1: `GET /items/{id}` → preview. Phase 2: `DELETE /items/{id}` after token consume. | `item_id` (required), `drive_id?`, `confirmation_token?` | preview or `{ deleted }` | `Files.ReadWrite` | **Spec/approval two-phase** per handbook ADR 0002. Token tied to canonical-JSON SHA-256 of the spec; passing a token issued for `{item_id: A}` together with `{item_id: B}` fails with `spec_mismatch`. Single-use, 5 min expiry. |
|
|
144
|
+
| `m365-graph:cancel_event` | Phase 1: `GET /events/{id}` → preview. Phase 2: `POST /events/{id}/cancel { Comment }` after token consume. | `event_id` (required), `comment?`, `confirmation_token?` | preview or `{ cancelled }` | `Calendars.ReadWrite` | Same two-phase pattern as delete_file. The `comment` is part of the spec — changing it between preview and execute fails `spec_mismatch`. |
|
|
145
|
+
| `m365-graph:decline_event` | Phase 1: `GET /events/{id}` → preview. Phase 2: `POST /events/{id}/decline { sendResponse, comment? }` after token consume. | `event_id`, `comment?`, `send_response?`, `confirmation_token?` | preview or `{ declined }` | `Calendars.ReadWrite` | For events the user is invited to (attendee). Cancel_event vs decline_event reflect the Graph distinction (organizer vs attendee). `send_response` is part of the spec hash so changing it between preview/execute fails `spec_mismatch`. Default true = organizer notified; false = silent decline. |
|
|
146
|
+
| `m365-graph:search_events_content` | `POST /search/query` with `entityTypes: ["event"]` + queryString | `query`, `limit?`, `from?` | `{ count, total, results: [<event summary>] }` | `Calendars.Read` | Subject + body search via the Microsoft Search API (separate from `$filter` on /me/events used by `search_events`). Search API hit shape `{ hitId, summary, resource }` mapped back to summarizeEvent's shape via `resource`. Returns recurrence series masters. |
|
|
147
|
+
|
|
148
|
+
## Spec/approval confirmation-token pattern
|
|
149
|
+
|
|
150
|
+
Destructive tools (`delete_file`, `cancel_event`) implement a two-phase
|
|
151
|
+
flow per the handbook MCP server spec § Tool design and ADR 0002 to
|
|
152
|
+
prevent single-shot agent destruction in long autonomous loops:
|
|
153
|
+
|
|
154
|
+
| Phase | Required args | Tool action |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| 1 — preview | original args, **no** `confirmation_token` | Fetches a preview of what would be destroyed; issues a token tied to (tool name, canonical-JSON SHA-256 of spec, expiry timestamp). Returns preview + token. |
|
|
157
|
+
| 2 — execute | original args + correct `confirmation_token` | Verifies token (exists, not expired, tied to this exact tool, spec hash matches). Executes the destructive operation. Consumes token (single-use). |
|
|
158
|
+
|
|
159
|
+
State lives in `src/auth/confirmation_tokens.ts` as a module-level
|
|
160
|
+
`Map<token, {toolName, specHash, expiresAt}>`. Per-tenant subprocess
|
|
161
|
+
per the handbook spec means there's no cross-process leakage; tokens
|
|
162
|
+
expire 5 minutes after issue and are garbage-collected on every
|
|
163
|
+
issue/consume call.
|
|
164
|
+
|
|
165
|
+
The spec match is by SHA-256 of canonicalized JSON (keys sorted, top
|
|
166
|
+
level only). The agent cannot reuse a token across destructive ops:
|
|
167
|
+
|
|
168
|
+
- Token issued for `delete_file({item_id: "A"})`
|
|
169
|
+
- Agent attempts `delete_file({item_id: "B", confirmation_token: <token>})`
|
|
170
|
+
- → `spec_mismatch` error, deletion does not occur
|
|
171
|
+
|
|
172
|
+
Same protection for `cancel_event` if the agent changes the cancellation
|
|
173
|
+
comment between preview and execute.
|
|
174
|
+
|
|
175
|
+
### Download sandboxing
|
|
176
|
+
|
|
177
|
+
The `download_file` tool writes to a sandbox directory determined by:
|
|
178
|
+
|
|
179
|
+
1. `M365_DOWNLOAD_DIR` env var (override) → `<override>/<tenant-id>/`
|
|
180
|
+
2. else `XDG_CACHE_HOME` → `<XDG_CACHE_HOME>/m365-graph-mcp-server/<tenant-id>/`
|
|
181
|
+
3. else default → `~/.cache/m365-graph-mcp-server/<tenant-id>/`
|
|
182
|
+
|
|
183
|
+
Local filenames are server-constructed: `<sha256(item_id)[:16]>-<sanitized name>`,
|
|
184
|
+
NOT derived from caller-supplied paths (handbook anti-pattern #1
|
|
185
|
+
mitigation). Path injection via `item_id` is structurally impossible
|
|
186
|
+
because the agent never sees raw filesystem paths in the input. As
|
|
187
|
+
defense-in-depth, the resolved path is verified to start with the
|
|
188
|
+
sandbox root before writing.
|
|
189
|
+
|
|
190
|
+
Mode: dirs `0o700`, files `0o600` so other users on a shared host
|
|
191
|
+
can't read downloaded content.
|
|
192
|
+
|
|
193
|
+
## Input validation
|
|
194
|
+
|
|
195
|
+
Every tool validates its inputs via helpers in
|
|
196
|
+
[`src/types/validators.ts`](src/types/validators.ts):
|
|
197
|
+
|
|
198
|
+
- `validateRequiredString(v, name)` — non-empty string or throw
|
|
199
|
+
- `validateOptionalString(v, name)` — `string | undefined`
|
|
200
|
+
- `validateOptionalInteger(v, name, {min, max, default})` — bounded
|
|
201
|
+
- `sanitizeFilename(name)` — strips `/`, `\`, `\0`, leading dots; caps at 200 chars
|
|
202
|
+
|
|
203
|
+
Naming convention: every helper starts with `validate*` or
|
|
204
|
+
`sanitize*` so the [CI dead-code grep](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md#ci-requirements)
|
|
205
|
+
enforces it's imported in at least one other file. Defense-in-depth
|
|
206
|
+
helpers that are never wired into a real handler are flagged as a
|
|
207
|
+
security smell (handbook anti-pattern S1).
|
|
208
|
+
|
|
209
|
+
## Dependencies
|
|
210
|
+
|
|
211
|
+
| Dependency | Version | Why |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `@modelcontextprotocol/sdk` | `^1.25.2` | MCP framing; ≥1.25.2 required (ReDoS + DNS rebinding fixes — audit C7) |
|
|
214
|
+
| `@microsoft/microsoft-graph-client` | `^3.0.7` | Microsoft's official Graph SDK — handles batching, retries, types |
|
|
215
|
+
| `@azure/msal-node` | `^5.0.0` | OAuth — never roll auth. v5+ avoids transitive `uuid <14` GHSA-w5hq-g745-h8pq. |
|
|
216
|
+
| `@napi-rs/keyring` | `^1.3.0` | OS keychain for token persistence; replaces archived `keytar`. |
|
|
217
|
+
| `isomorphic-fetch` | `^3.0.0` | Peer dep of the Graph SDK. |
|
|
218
|
+
|
|
219
|
+
## References
|
|
220
|
+
|
|
221
|
+
- [Handbook MCP server spec](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md)
|
|
222
|
+
- [Handbook MCP abstract roles ADR (0002)](https://github.com/juvantlabs/handbook/blob/main/docs/adr/0002-mcp-abstract-roles.md)
|
|
223
|
+
- [Handbook security disclosure process](https://github.com/juvantlabs/handbook/blob/main/docs/security/disclosure-process.md)
|
|
224
|
+
- [Juvant OS MCP_INVENTORY.md](https://github.com/juvantlabs/juvant-os/blob/main/docs/MCP_INVENTORY.md)
|
|
225
|
+
- [ftaricano audit (2026-05-03)](https://gist.github.com/juvantlabs/a9fe0a76a23b0c1260b1e0ad3194a6da) — origin of the 12-item anti-pattern checklist
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@juvantlabs/m365-graph-mcp-server` will be documented in this
|
|
4
|
+
file.
|
|
5
|
+
|
|
6
|
+
The format is based on
|
|
7
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
8
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## [Unreleased]
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Initial scaffold per handbook
|
|
17
|
+
[`docs/repo-types/mcp-server.md`](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md),
|
|
18
|
+
generated by `juvantlabs/juvant-tools` `scaffold mcp-server` on
|
|
19
|
+
2026-05-03.
|
|
20
|
+
- Authentication wiring under `src/auth/`:
|
|
21
|
+
- `msal.ts` — `ConfidentialClientApplication` factory with delegated
|
|
22
|
+
scopes (`User.Read`, `Files.Read`, `Calendars.Read`, `offline_access`)
|
|
23
|
+
and an MSAL cache plugin.
|
|
24
|
+
- `keyring.ts` — token persistence via `@napi-rs/keyring` (OS
|
|
25
|
+
keychain). Per-tenant scoping so multiple tenant configs don't
|
|
26
|
+
collide.
|
|
27
|
+
- `setup.ts` — interactive OAuth flow: opens the browser, runs a
|
|
28
|
+
one-shot localhost listener for the redirect, exchanges the code
|
|
29
|
+
for tokens, persists in keychain.
|
|
30
|
+
- Microsoft Graph client factory under `src/client/graph.ts` — wraps
|
|
31
|
+
`@microsoft/microsoft-graph-client` with an MSAL-backed
|
|
32
|
+
authentication provider. Refresh handled transparently.
|
|
33
|
+
- First read tool `m365-graph:list_drives` under `src/tools/`. Returns
|
|
34
|
+
the user's primary OneDrive plus other drives (shared SharePoint
|
|
35
|
+
document libraries) accessible to them.
|
|
36
|
+
- Consolidation block — body-content search, decline-as-attendee,
|
|
37
|
+
mock-based auth tests:
|
|
38
|
+
- `m365-graph:search_events_content` — body + subject search via
|
|
39
|
+
the Microsoft Search API (POST /search/query). Distinct from
|
|
40
|
+
search_events which is subject-only via $filter (since Graph
|
|
41
|
+
doesn't support $search on /me/events). Maps Search-API-shaped
|
|
42
|
+
hits back to summarizeEvent for response uniformity. Pagination
|
|
43
|
+
via `from` + `limit`. Read-only.
|
|
44
|
+
- `m365-graph:decline_event` — decline an event the user is invited
|
|
45
|
+
to (distinct from cancel_event which is for events the user
|
|
46
|
+
organizes). Two-phase spec/approval pattern, identical to
|
|
47
|
+
cancel_event's. `send_response` boolean controls whether the
|
|
48
|
+
organizer is notified — both default-true (sends RSVP) and
|
|
49
|
+
silent-decline are supported. send_response is part of the spec
|
|
50
|
+
hash, so changing it between preview and execute fails
|
|
51
|
+
spec_mismatch.
|
|
52
|
+
- Mock-based unit tests landed for src/auth/keyring.ts,
|
|
53
|
+
src/auth/msal.ts (cache plugin lifecycle + makeMsalClient
|
|
54
|
+
factory), src/client/graph.ts (MsalAuthProvider class), and
|
|
55
|
+
src/index.ts (validateEnv → renamed checkEnv to dodge the dead-
|
|
56
|
+
code grep, dispatch, dispatchToolCall extracted from runMcpServer
|
|
57
|
+
for testability).
|
|
58
|
+
- vitest.config.ts coverage scope expanded: src/auth/** + src/client/**
|
|
59
|
+
now in scope. src/auth/setup.ts and src/index.ts excluded from
|
|
60
|
+
coverage thresholds — both are entry-point/integration glue best
|
|
61
|
+
validated via the live OAuth + MCP smoke runs.
|
|
62
|
+
- Write block, round 2 — four tools completing the file + calendar
|
|
63
|
+
write surface. No new Entra scopes required (round 1 already extended
|
|
64
|
+
to Files.ReadWrite + Calendars.ReadWrite).
|
|
65
|
+
- `m365-graph:copy_file` — async copy with monitor-URL polling
|
|
66
|
+
(POST /items/{id}/copy → 202 + Location → poll until completion).
|
|
67
|
+
Exponential backoff (1s → 2s → 4s → … capped at 30s). Per
|
|
68
|
+
handbook anti-pattern S8: never returns "initiated successfully";
|
|
69
|
+
always waits for terminal state. Fallback list-by-name if the
|
|
70
|
+
Graph monitor URL's completed response omits `resourceLocation`
|
|
71
|
+
(a documented but inconsistent Graph behavior).
|
|
72
|
+
- `m365-graph:move_file` — synchronous PATCH with `parentReference`.
|
|
73
|
+
Atomic within a single drive. Cross-drive is documented as
|
|
74
|
+
unsupported (use copy + delete instead).
|
|
75
|
+
- `m365-graph:delete_file` — two-phase spec/approval per handbook
|
|
76
|
+
ADR 0002. First call returns a preview + confirmation_token tied
|
|
77
|
+
to the exact spec; second call (with the token + same args)
|
|
78
|
+
executes DELETE. Token is single-use, 5 min expiry, SHA-256
|
|
79
|
+
matched against canonical JSON of the spec — passing a stale
|
|
80
|
+
token with different args fails `spec_mismatch`.
|
|
81
|
+
- `m365-graph:cancel_event` — same two-phase pattern. Sends
|
|
82
|
+
cancellation notice to attendees after token consume.
|
|
83
|
+
- New `src/auth/confirmation_tokens.ts` — module-level Map keyed by
|
|
84
|
+
token, with `issueConfirmation(tool, spec)` and
|
|
85
|
+
`consumeConfirmation(token, tool, spec)`. Per-tenant subprocess
|
|
86
|
+
scoping inherited from the spec; no cross-process leakage.
|
|
87
|
+
Garbage-collected on every issue/consume.
|
|
88
|
+
- Write block, round 1 — three tools requiring Files.ReadWrite +
|
|
89
|
+
Calendars.ReadWrite delegated scopes (extended in Entra app
|
|
90
|
+
permissions, admin consent re-granted, OAuth re-run):
|
|
91
|
+
- `m365-graph:upload_file` — uploads a local file to a drive.
|
|
92
|
+
Auto-routes between single PUT (`PUT /items/{parent}:/{name}:/content`,
|
|
93
|
+
files ≤ 4 MB) and resumable upload session
|
|
94
|
+
(`OneDriveLargeFileUploadTask` with 10 MB chunks, files > 4 MB).
|
|
95
|
+
200 MB hard cap. Conflict behavior parametric (`fail` default,
|
|
96
|
+
`replace`, `rename`). Trust note: agent supplies `local_path`;
|
|
97
|
+
the absolute path is logged to stderr.
|
|
98
|
+
- `m365-graph:create_event` — `POST /me/events`. Subject + start +
|
|
99
|
+
end required; timezone defaults to UTC. Optional body (text/html),
|
|
100
|
+
location, attendees (with type ∈ {required, optional, resource}),
|
|
101
|
+
is_all_day. Graph sends invitations by default.
|
|
102
|
+
- `m365-graph:update_event` — `PATCH /me/events/{id}`. All fields
|
|
103
|
+
except event_id optional; only provided fields are PATCHed. Empty
|
|
104
|
+
patch is rejected. Attendees are REPLACED (Graph semantics, not
|
|
105
|
+
merged).
|
|
106
|
+
- DELEGATED_SCOPES updated: `Files.Read` + `Calendars.Read` →
|
|
107
|
+
`Files.ReadWrite` + `Calendars.ReadWrite` (the wider scopes subsume
|
|
108
|
+
the narrower; the Entra app permissions list still includes both
|
|
109
|
+
for granted-permission tracking).
|
|
110
|
+
- New `validateOptionalEnum<T>(value, name, allowed, default)` validator
|
|
111
|
+
for parametric strings (conflict_behavior, attendee.type,
|
|
112
|
+
body_content_type).
|
|
113
|
+
- `checkSizeCap(size)` extracted from upload_file's handler so the
|
|
114
|
+
200-MB defense-in-depth can be unit-tested independently of fs +
|
|
115
|
+
Graph integration.
|
|
116
|
+
- Calendar read block — four tools under the existing `Calendars.Read`
|
|
117
|
+
delegated scope (no new Entra app permissions required):
|
|
118
|
+
- `m365-graph:list_calendars` — list user calendars (primary + group
|
|
119
|
+
+ shared) via `/me/calendars`. Returns id / name / color / owner /
|
|
120
|
+
is_default / can_edit / can_share per calendar.
|
|
121
|
+
- `m365-graph:list_events` — events in a date window via
|
|
122
|
+
`/me/calendarView` (or `/me/calendars/{id}/calendarView`) with
|
|
123
|
+
`startDateTime` + `endDateTime` query params. **Recurrences are
|
|
124
|
+
expanded** — each occurrence is its own event in the response.
|
|
125
|
+
Ordered by start/dateTime ascending.
|
|
126
|
+
- `m365-graph:search_events` — subject-substring search via
|
|
127
|
+
`$filter=contains(subject, '…')`. `$search` is not supported on
|
|
128
|
+
the Events resource by Graph; body search would require the
|
|
129
|
+
Search API (POST /search/query, deferred). Returns series masters
|
|
130
|
+
for recurring events.
|
|
131
|
+
- `m365-graph:get_event` — full event details via `/me/events/{id}`.
|
|
132
|
+
Body content capped at 8000 chars with `body_truncated` flag.
|
|
133
|
+
Includes recurrence rule when present.
|
|
134
|
+
- New `validateRequiredISODate` validator with regex-based ISO 8601
|
|
135
|
+
shape check (date-only or full datetime, with optional Z or ±HH:MM
|
|
136
|
+
offset). Catches obviously-wrong inputs near the source; Graph does
|
|
137
|
+
the strict parse.
|
|
138
|
+
- Three additional read tools, all under the `Files.Read` delegated
|
|
139
|
+
scope:
|
|
140
|
+
- `m365-graph:list_items` — list children of a folder (drive root or
|
|
141
|
+
specific folder via `item_id`). Distinguishes file vs folder via
|
|
142
|
+
presence of the `folder` facet; populates `child_count` for folders.
|
|
143
|
+
- `m365-graph:search_files` — OData search within a drive. Defaults
|
|
144
|
+
to the user's primary OneDrive; supports `drive_id` for SharePoint
|
|
145
|
+
libraries. Single-quote escaping in the query string.
|
|
146
|
+
- `m365-graph:download_file` — streams a file to a per-tenant local
|
|
147
|
+
sandbox (XDG-compliant default, `M365_DOWNLOAD_DIR` override).
|
|
148
|
+
200 MB cap enforced via metadata pre-check. Local filename is
|
|
149
|
+
server-constructed (`<sha256(item_id)[:16]>-<sanitized name>`) so
|
|
150
|
+
path injection is structurally impossible. 0o700 dir + 0o600 file
|
|
151
|
+
mode. Streamed via fetch + Node pipeline (no whole-file buffering
|
|
152
|
+
— handbook anti-pattern #11 mitigation).
|
|
153
|
+
- Input validators in `src/types/validators.ts`
|
|
154
|
+
(`validateRequiredString`, `validateOptionalString`,
|
|
155
|
+
`validateOptionalInteger`, `sanitizeFilename`). The `validate*` /
|
|
156
|
+
`sanitize*` naming feeds into the CI dead-code grep.
|
|
157
|
+
- Tool registry pattern (`src/tools/index.ts` + `src/types/tool.ts`)
|
|
158
|
+
so subsequent tools land via single-file additions.
|
|
159
|
+
- `setup` subcommand on the binary (`m365-graph-mcp-server setup`,
|
|
160
|
+
`npm run setup`) that runs the OAuth flow and exits. The default
|
|
161
|
+
invocation (no subcommand) stays the stdio MCP server.
|
|
162
|
+
- `.env.example` documenting the required env vars +
|
|
163
|
+
`npm run dev` / `npm run setup` scripts that load `.env.local` via
|
|
164
|
+
Node's `--env-file` flag.
|
|
165
|
+
|
|
166
|
+
### Pending
|
|
167
|
+
|
|
168
|
+
- Integration tests against a live sandbox tenant in CI (currently
|
|
169
|
+
smoke-run live by hand).
|
|
170
|
+
- Refresh-token revocation handling tests (MSAL silent acquisition
|
|
171
|
+
failure path beyond "no cached account").
|
|
172
|
+
- Mail send / Teams chat — explicit non-goals, deferred to separate
|
|
173
|
+
vendor MCP servers if needed.
|
|
174
|
+
|
|
175
|
+
### Tested
|
|
176
|
+
|
|
177
|
+
- Vitest unit tests covering all eight read tools + validator helpers
|
|
178
|
+
+ tool registry. 106 tests across 10 files. Per-file coverage
|
|
179
|
+
thresholds (lines/functions/statements ≥ 80%, branches ≥ 50%
|
|
180
|
+
pending fs+fetch mocking on download_file) pass for every file in
|
|
181
|
+
the configured coverage scope (`src/types/validators.ts` +
|
|
182
|
+
`src/tools/**`). All tools at 100% line coverage; branches between
|
|
183
|
+
52–100%. `src/index.ts`, `src/auth/**`, `src/client/**` are still
|
|
184
|
+
deferred to integration tests.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
[Unreleased]: https://github.com/juvantlabs/m365-graph-mcp-server/compare/HEAD
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Juvant Srls
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# M365 Graph MCP Server
|
|
2
|
+
|
|
3
|
+
`@juvantlabs/m365-graph-mcp-server` — Model Context Protocol server
|
|
4
|
+
wrapping the Microsoft Graph API for OneDrive, SharePoint, and Calendar
|
|
5
|
+
(read + write). Designed to be consumed by Juvant OS agents (or any
|
|
6
|
+
MCP-aware client) via `npx`.
|
|
7
|
+
|
|
8
|
+
Fulfills the `m365-graph` abstract role per
|
|
9
|
+
[`docs/adr/0002-mcp-abstract-roles.md`](https://github.com/juvantlabs/handbook/blob/main/docs/adr/0002-mcp-abstract-roles.md)
|
|
10
|
+
in the handbook. Per-company instance config binds this concrete server
|
|
11
|
+
to that abstract role in `.juvant/config.json`.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
**Scaffolded.** Generated by
|
|
16
|
+
[`juvantlabs/juvant-tools`](https://github.com/juvantlabs/juvant-tools)
|
|
17
|
+
`scaffold mcp-server` (via the `juvant-tools-mcp` MCP server) on
|
|
18
|
+
2026-05-03, conforming to the
|
|
19
|
+
[`mcp-server.md`](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md)
|
|
20
|
+
spec. Tool implementations land via PR.
|
|
21
|
+
|
|
22
|
+
## Install + run
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @juvantlabs/m365-graph-mcp-server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
(Once published to npm. During development, see [`CONTRIBUTING.md`](CONTRIBUTING.md).)
|
|
29
|
+
|
|
30
|
+
## Environment variables
|
|
31
|
+
|
|
32
|
+
Required:
|
|
33
|
+
|
|
34
|
+
| Variable | Purpose |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `M365_CLIENT_ID` | Microsoft Entra application (client) ID for the registered app. |
|
|
37
|
+
| `M365_CLIENT_SECRET` | Client secret for the registered app. Stored only in the consumer's environment; never in `.juvant/config.json`. |
|
|
38
|
+
| `M365_TENANT_ID` | Microsoft Entra tenant ID (UUID), or one of `common` / `organizations` / `consumers` for multi-tenant flows. Validated at startup against the spec regex. |
|
|
39
|
+
|
|
40
|
+
Optional:
|
|
41
|
+
|
|
42
|
+
| Variable | Purpose |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `MCP_SERVER_LOG_LEVEL` | Log level for diagnostics on stderr (default `info`). |
|
|
45
|
+
| `M365_DOWNLOAD_DIR` | Override the per-tenant sandbox directory used by `download_file`. Default: `$XDG_CACHE_HOME/m365-graph-mcp-server/<tenant-id>` or `~/.cache/m365-graph-mcp-server/<tenant-id>`. |
|
|
46
|
+
|
|
47
|
+
> CI enforces that every variable documented in this section is actually
|
|
48
|
+
> read from `process.env.<NAME>` somewhere in `src/` — placeholder names
|
|
49
|
+
> containing `<>` are skipped. Documenting an env var without wiring it
|
|
50
|
+
> up will fail the build (handbook anti-pattern S2).
|
|
51
|
+
|
|
52
|
+
OAuth scope minimization is per-tool; see the [tool catalog](#tools)
|
|
53
|
+
and [`ARCHITECTURE.md`](ARCHITECTURE.md) for the per-tool scope
|
|
54
|
+
justifications.
|
|
55
|
+
|
|
56
|
+
## Binding
|
|
57
|
+
|
|
58
|
+
The Juvant OS adopter binds this server in `.juvant/config.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"m365-graph": {
|
|
63
|
+
"provider": "microsoft",
|
|
64
|
+
"mcp_server": "npx @juvantlabs/m365-graph-mcp-server",
|
|
65
|
+
"scope": "rw"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
See [handbook MCP_INVENTORY.md](https://github.com/juvantlabs/juvant-os/blob/main/docs/MCP_INVENTORY.md)
|
|
71
|
+
for the abstract role this server fulfills + the canonical config shape.
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
| Tool | Purpose | Input | Output | Required scope |
|
|
76
|
+
|---|---|---|---|---|
|
|
77
|
+
| `m365-graph:list_drives` | Lists the drives the user has access to (primary OneDrive + shared document libraries). | _(none)_ | `{ primary, accessible: [] }` with id / driveType / name / webUrl / owner. | `Files.Read` |
|
|
78
|
+
| `m365-graph:list_items` | Lists immediate children (files + folders) of a folder. Defaults to the drive root. | `drive_id?`, `item_id?`, `limit?` (1–100, default 50) | `{ count, items: [] }` with id / name / type / size / child_count / lastModified / webUrl. | `Files.Read` |
|
|
79
|
+
| `m365-graph:search_files` | Searches files by name and content within a drive. | `query` (required), `drive_id?`, `limit?` (1–50, default 20) | `{ count, results: [] }` with id / name / path / size / is_folder / lastModified / webUrl. | `Files.Read` |
|
|
80
|
+
| `m365-graph:download_file` | Downloads a file to a per-tenant local sandbox. Returns the local path; agent reads via a filesystem-aware tool. Streams, capped at 200 MB. | `item_id` (required), `drive_id?` | `{ local_path, size_bytes, name, content_type }` | `Files.Read` |
|
|
81
|
+
| `m365-graph:list_calendars` | Lists the user's calendars (primary + group / shared). | `limit?` (1–100, default 50) | `{ count, calendars: [] }` with id / name / color / owner / is_default / can_edit / can_share. | `Calendars.Read` |
|
|
82
|
+
| `m365-graph:list_events` | Lists events in a date window. Recurrences are expanded — each occurrence is its own event. | `start` + `end` (ISO 8601, required), `calendar_id?`, `limit?` (1–200, default 100) | `{ window, count, events: [] }` with id / subject / start / end / location / organizer / attendees / web_url. | `Calendars.Read` |
|
|
83
|
+
| `m365-graph:search_events` | Searches events by subject substring (Graph $search isn't supported on Events; subject-only via `contains()`). Returns recurrence series masters, not occurrences. | `query` (required), `limit?` (1–50, default 20) | `{ count, results: [] }` (same event shape). | `Calendars.Read` |
|
|
84
|
+
| `m365-graph:get_event` | Fetches full details for a single event — body (capped at 8000 chars), attendees with response statuses, location, recurrence rule. | `event_id` (required) | event summary + `body` / `body_content_type` / `body_truncated` / `recurrence`. | `Calendars.Read` |
|
|
85
|
+
| `m365-graph:upload_file` | Uploads a local file to a drive. Auto-routes between single PUT (≤ 4 MB) and resumable upload session (> 4 MB, 10 MB chunks). 200 MB hard cap. | `local_path` (required), `drive_id?`, `parent_item_id?`, `name?`, `conflict_behavior?` (`fail`/`replace`/`rename`, default `fail`) | `{ uploaded: { id, name, size, webUrl, upload_path } }` | `Files.ReadWrite` |
|
|
86
|
+
| `m365-graph:create_event` | Creates a new event on the user's primary calendar (or a specified calendar). Sends invitations to attendees by Graph default. | `subject` + `start` + `end` (required), `timezone?` (default UTC), `body?`, `body_content_type?` (`text`/`html`), `location?`, `attendees?`, `is_all_day?`, `calendar_id?` | `{ created: <event summary> }` | `Calendars.ReadWrite` |
|
|
87
|
+
| `m365-graph:update_event` | Updates an existing event. All fields except `event_id` are optional; only provided fields are PATCHed. **Attendees: full replacement, not merge** — pass the full intended list. | `event_id` (required), then any subset of `subject`/`start`+`end`+`timezone`/`body`+`body_content_type`/`location`/`attendees`/`is_all_day` | `{ updated: <event summary> }` | `Calendars.ReadWrite` |
|
|
88
|
+
| `m365-graph:copy_file` | Async copy with polling. POSTs to `/items/{id}/copy`, polls the monitor URL with exponential backoff (1s → 2s → … capped at 30s) until completion. Falls back to `list-by-name` if the monitor's completed response omits `resourceLocation` (common Graph quirk). | `item_id` + `target_parent_id` (required); `source_drive_id?`, `target_drive_id?`, `new_name?`, `wait_max_seconds?` (1–1800, default 300) | `{ status: "completed", copied: { id, name, ... } }` | `Files.ReadWrite` |
|
|
89
|
+
| `m365-graph:move_file` | Synchronous move within a drive (PATCH parentReference). Cross-drive moves are not supported here — use copy_file + delete_file for those. | `item_id` + `target_parent_id` (required); `drive_id?`, `new_name?` | `{ moved: { id, name, ... } }` | `Files.ReadWrite` |
|
|
90
|
+
| `m365-graph:delete_file` | **Two-phase** spec/approval: 1st call returns preview + `confirmation_token`; 2nd call (same args + token) executes the DELETE. Token single-use, 5 min expiry, tied to exact spec (canonical-JSON SHA-256). | `item_id` (required), `drive_id?`, `confirmation_token?` | preview `{ item, confirmation_token, expires_at }` or execute `{ deleted: { ... } }` | `Files.ReadWrite` |
|
|
91
|
+
| `m365-graph:cancel_event` | **Two-phase** like delete_file. Cancels a meeting the user organizes (sends cancellation notice to attendees). | `event_id` (required), `comment?`, `confirmation_token?` | preview or `{ cancelled: { event_id } }` | `Calendars.ReadWrite` |
|
|
92
|
+
| `m365-graph:decline_event` | **Two-phase**. Declines an event the user is invited to (as attendee — distinct from cancel which is for events the user organizes). Sends a decline RSVP unless `send_response: false`. | `event_id` (required), `comment?`, `send_response?` (default `true`), `confirmation_token?` | preview or `{ declined: { event_id, send_response } }` | `Calendars.ReadWrite` |
|
|
93
|
+
| `m365-graph:search_events_content` | Subject + **body** content search via the Microsoft Search API (POST `/search/query`). Distinct from `search_events` (subject-only via `$filter`). Returns recurrence series masters; for occurrences in a window use `list_events`. | `query` (required), `limit?` (1–50, default 25), `from?` (pagination offset, default 0) | `{ count, total, results: [<event summary>] }` | `Calendars.Read` |
|
|
94
|
+
|
|
95
|
+
That's the full FEAT-014 surface: 4 read + 4 write on files, 5 read +
|
|
96
|
+
4 write on calendars — **17 tools total**. Read tools all exercise
|
|
97
|
+
delegated `Files.Read` + `Calendars.Read` (granted by default in the
|
|
98
|
+
narrower `*.Read` permissions); write tools require `Files.ReadWrite`
|
|
99
|
+
+ `Calendars.ReadWrite` (separately granted in the Entra app +
|
|
100
|
+
admin-consented).
|
|
101
|
+
|
|
102
|
+
## Local development
|
|
103
|
+
|
|
104
|
+
The repo expects a `.env.local` file with your tenant's credentials.
|
|
105
|
+
Bootstrap from the template:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
cp .env.example .env.local
|
|
109
|
+
# then edit .env.local with your M365_TENANT_ID, M365_CLIENT_ID,
|
|
110
|
+
# and M365_CLIENT_SECRET — see ARCHITECTURE.md § Authentication
|
|
111
|
+
# for the Entra app registration flow.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`.env.local` is gitignored.
|
|
115
|
+
|
|
116
|
+
### One-time OAuth setup
|
|
117
|
+
|
|
118
|
+
The first time you run the server, you need to complete an OAuth flow
|
|
119
|
+
to populate the OS keychain with refresh tokens:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm run setup
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This opens your browser, signs you in to your tenant, captures the
|
|
126
|
+
authorization code via a one-shot listener at
|
|
127
|
+
`http://localhost:3000/auth/callback`, and persists the resulting
|
|
128
|
+
tokens via `@napi-rs/keyring` (macOS Keychain / Linux Secret Service /
|
|
129
|
+
Windows Credential Manager). After that, the server uses cached
|
|
130
|
+
tokens silently — refreshes as needed via the cached refresh grant.
|
|
131
|
+
|
|
132
|
+
### Run the MCP server
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm run dev
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Listens on stdio. Useful when developing alongside an MCP client like
|
|
139
|
+
Claude Code: configure the client to spawn `npm run dev` (or
|
|
140
|
+
`tsx --env-file=.env.local src/index.ts`) as its MCP server command.
|
|
141
|
+
|
|
142
|
+
## Architecture
|
|
143
|
+
|
|
144
|
+
See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design rationale: scope,
|
|
145
|
+
OAuth model with `@azure/msal-node`, token persistence via
|
|
146
|
+
`@napi-rs/keyring`, per-tool scope minimization, filesystem sandboxing
|
|
147
|
+
for upload/download tools, and async-op polling for `copy` / `move`.
|
|
148
|
+
|
|
149
|
+
## Contributing
|
|
150
|
+
|
|
151
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md). The repo follows the
|
|
152
|
+
[`juvantlabs/handbook`](https://github.com/juvantlabs/handbook)
|
|
153
|
+
conventions for MCP server repos.
|
|
154
|
+
|
|
155
|
+
## Security
|
|
156
|
+
|
|
157
|
+
See [`SECURITY.md`](SECURITY.md) for the disclosure process. Per the
|
|
158
|
+
[handbook security disclosure process](https://github.com/juvantlabs/handbook/blob/main/docs/security/disclosure-process.md),
|
|
159
|
+
report vulnerabilities privately via GitHub Security Advisory or
|
|
160
|
+
`security@juvant.io`.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE). Copyright (c) 2026 Juvant Srls.
|