@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.
Files changed (110) hide show
  1. package/ARCHITECTURE.md +225 -0
  2. package/CHANGELOG.md +188 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -0
  5. package/SECURITY.md +64 -0
  6. package/dist/auth/confirmation_tokens.d.ts +38 -0
  7. package/dist/auth/confirmation_tokens.d.ts.map +1 -0
  8. package/dist/auth/confirmation_tokens.js +85 -0
  9. package/dist/auth/confirmation_tokens.js.map +1 -0
  10. package/dist/auth/keyring.d.ts +20 -0
  11. package/dist/auth/keyring.d.ts.map +1 -0
  12. package/dist/auth/keyring.js +41 -0
  13. package/dist/auth/keyring.js.map +1 -0
  14. package/dist/auth/msal.d.ts +42 -0
  15. package/dist/auth/msal.d.ts.map +1 -0
  16. package/dist/auth/msal.js +96 -0
  17. package/dist/auth/msal.js.map +1 -0
  18. package/dist/auth/setup.d.ts +18 -0
  19. package/dist/auth/setup.d.ts.map +1 -0
  20. package/dist/auth/setup.js +110 -0
  21. package/dist/auth/setup.js.map +1 -0
  22. package/dist/client/graph.d.ts +30 -0
  23. package/dist/client/graph.d.ts.map +1 -0
  24. package/dist/client/graph.js +38 -0
  25. package/dist/client/graph.js.map +1 -0
  26. package/dist/index.d.ts +54 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +131 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/tools/cancel_event.d.ts +18 -0
  31. package/dist/tools/cancel_event.d.ts.map +1 -0
  32. package/dist/tools/cancel_event.js +95 -0
  33. package/dist/tools/cancel_event.js.map +1 -0
  34. package/dist/tools/copy_file.d.ts +39 -0
  35. package/dist/tools/copy_file.d.ts.map +1 -0
  36. package/dist/tools/copy_file.js +168 -0
  37. package/dist/tools/copy_file.js.map +1 -0
  38. package/dist/tools/create_event.d.ts +29 -0
  39. package/dist/tools/create_event.d.ts.map +1 -0
  40. package/dist/tools/create_event.js +144 -0
  41. package/dist/tools/create_event.js.map +1 -0
  42. package/dist/tools/decline_event.d.ts +18 -0
  43. package/dist/tools/decline_event.d.ts.map +1 -0
  44. package/dist/tools/decline_event.js +105 -0
  45. package/dist/tools/decline_event.js.map +1 -0
  46. package/dist/tools/delete_file.d.ts +28 -0
  47. package/dist/tools/delete_file.d.ts.map +1 -0
  48. package/dist/tools/delete_file.js +103 -0
  49. package/dist/tools/delete_file.js.map +1 -0
  50. package/dist/tools/download_file.d.ts +43 -0
  51. package/dist/tools/download_file.d.ts.map +1 -0
  52. package/dist/tools/download_file.js +133 -0
  53. package/dist/tools/download_file.js.map +1 -0
  54. package/dist/tools/get_event.d.ts +27 -0
  55. package/dist/tools/get_event.d.ts.map +1 -0
  56. package/dist/tools/get_event.js +55 -0
  57. package/dist/tools/get_event.js.map +1 -0
  58. package/dist/tools/index.d.ts +13 -0
  59. package/dist/tools/index.d.ts.map +1 -0
  60. package/dist/tools/index.js +61 -0
  61. package/dist/tools/index.js.map +1 -0
  62. package/dist/tools/list_calendars.d.ts +26 -0
  63. package/dist/tools/list_calendars.d.ts.map +1 -0
  64. package/dist/tools/list_calendars.js +60 -0
  65. package/dist/tools/list_calendars.js.map +1 -0
  66. package/dist/tools/list_drives.d.ts +27 -0
  67. package/dist/tools/list_drives.d.ts.map +1 -0
  68. package/dist/tools/list_drives.js +58 -0
  69. package/dist/tools/list_drives.js.map +1 -0
  70. package/dist/tools/list_events.d.ts +51 -0
  71. package/dist/tools/list_events.d.ts.map +1 -0
  72. package/dist/tools/list_events.js +119 -0
  73. package/dist/tools/list_events.js.map +1 -0
  74. package/dist/tools/list_items.d.ts +31 -0
  75. package/dist/tools/list_items.d.ts.map +1 -0
  76. package/dist/tools/list_items.js +81 -0
  77. package/dist/tools/list_items.js.map +1 -0
  78. package/dist/tools/move_file.d.ts +18 -0
  79. package/dist/tools/move_file.d.ts.map +1 -0
  80. package/dist/tools/move_file.js +60 -0
  81. package/dist/tools/move_file.js.map +1 -0
  82. package/dist/tools/search_events.d.ts +25 -0
  83. package/dist/tools/search_events.d.ts.map +1 -0
  84. package/dist/tools/search_events.js +71 -0
  85. package/dist/tools/search_events.js.map +1 -0
  86. package/dist/tools/search_events_content.d.ts +32 -0
  87. package/dist/tools/search_events_content.d.ts.map +1 -0
  88. package/dist/tools/search_events_content.js +106 -0
  89. package/dist/tools/search_events_content.js.map +1 -0
  90. package/dist/tools/search_files.d.ts +30 -0
  91. package/dist/tools/search_files.d.ts.map +1 -0
  92. package/dist/tools/search_files.js +82 -0
  93. package/dist/tools/search_files.js.map +1 -0
  94. package/dist/tools/update_event.d.ts +25 -0
  95. package/dist/tools/update_event.d.ts.map +1 -0
  96. package/dist/tools/update_event.js +123 -0
  97. package/dist/tools/update_event.js.map +1 -0
  98. package/dist/tools/upload_file.d.ts +38 -0
  99. package/dist/tools/upload_file.d.ts.map +1 -0
  100. package/dist/tools/upload_file.js +152 -0
  101. package/dist/tools/upload_file.js.map +1 -0
  102. package/dist/types/tool.d.ts +32 -0
  103. package/dist/types/tool.d.ts.map +1 -0
  104. package/dist/types/tool.js +10 -0
  105. package/dist/types/tool.js.map +1 -0
  106. package/dist/types/validators.d.ts +44 -0
  107. package/dist/types/validators.d.ts.map +1 -0
  108. package/dist/types/validators.js +78 -0
  109. package/dist/types/validators.js.map +1 -0
  110. package/package.json +72 -0
@@ -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.