Skip to main content
Glama
juvantlabs
by juvantlabs
README.md
# M365 Graph MCP Server

`@juvantlabs/m365-graph-mcp-server` — Model Context Protocol server
wrapping the Microsoft Graph API for OneDrive, SharePoint, and Calendar
(read + write). Designed to be consumed by Juvant OS agents (or any
MCP-aware client) via `npx`.

Fulfills the `m365-graph` role per
[`docs/adr/0002-mcp-abstract-roles.md`](https://github.com/juvantlabs/handbook/blob/main/docs/adr/0002-mcp-abstract-roles.md)
in the handbook (concrete-only — Microsoft Graph is the single
provider). The scope of this server (files + calendar; **no mail
send, no Teams chat**) follows the threat-model boundary rule in
[`docs/adr/0003-mcp-server-scope-boundaries.md`](https://github.com/juvantlabs/handbook/blob/main/docs/adr/0003-mcp-server-scope-boundaries.md):
mail and Teams chat have materially different blast radius and would
ship as separate `<vendor>-<capability>-mcp-server` packages if a
real Juvant OS need surfaces; outbound Teams notifications go through
webhooks (Adaptive Cards), not MCP. Per-company instance config binds
this server in `.juvant/config.json`.

## What's new

**Teams meeting transcript support.** Two new read-only tools:

- **`m365-graph:list_meeting_transcripts`** — given a calendar event ID, lists available post-meeting transcripts. Returns transcript IDs to pass to `get_transcript`.
- **`m365-graph:get_transcript`** — fetches the transcript content, strips VTT timing markers, and returns clean readable text.

Two new delegated scopes required (admin consent — see § Tools):
`OnlineMeetings.Read` and `OnlineMeetingTranscript.Read.All`.
**Re-run `npm run setup` after upgrading** to acquire them.

See [CHANGELOG.md](CHANGELOG.md) for the full change list.

## Status

**Published.** v0.4.0 on npm
([`@juvantlabs/m365-graph-mcp-server`](https://www.npmjs.com/package/@juvantlabs/m365-graph-mcp-server)),
released 2026-06-26. 19 tools across files (OneDrive + SharePoint),
Outlook Calendar, and Teams meeting transcripts. v0.4.1 is prepared
on `main` — adds `create_folder` (bringing the tool count to 20) and
fixes the `consumeConfirmation` gc-ordering bug; pending manual tag +
release trigger. Published via npm **Trusted Publishing**
(OIDC-based auth from GitHub Actions; no static `NPM_TOKEN`) with
provenance attestation; manual approval gate on the `production`
GitHub Environment guards the publish step.

Originally generated by
[`juvantlabs/juvant-tools`](https://github.com/juvantlabs/juvant-tools)
`scaffold mcp-server` on 2026-05-03, conforming to the
[`mcp-server.md`](https://github.com/juvantlabs/handbook/blob/main/docs/repo-types/mcp-server.md)
spec. See [`CHANGELOG.md`](CHANGELOG.md) for the per-version history.

## Install + run

```bash
# One-time OAuth (opens browser, persists tokens in OS keychain):
npx @juvantlabs/m365-graph-mcp-server setup

# Run the MCP server on stdio (default subcommand):
npx @juvantlabs/m365-graph-mcp-server
```

Requires Node ≥ 20. Both invocations expect the env vars below
(typically loaded from `.env.local` via `--env-file`, or set by
your MCP client when it spawns the server).

## Environment variables

Required:

| Variable | Purpose |
|---|---|
| `M365_CLIENT_ID` | Microsoft Entra application (client) ID for the registered app. |
| `M365_CLIENT_SECRET` | Client secret for the registered app. Stored only in the consumer's environment; never in `.juvant/config.json`. |
| `M365_TENANT_ID` | Microsoft Entra tenant ID (UUID). The canonical adoption pattern is **single-tenant** — see [ARCHITECTURE.md § Tenancy model](ARCHITECTURE.md#tenancy-model). The regex also accepts `common` / `organizations` / `consumers` for technical compatibility with Microsoft authority strings, but multi-tenant operation is not the supported deployment shape. |

Optional:

| Variable | Purpose |
|---|---|
| `MCP_SERVER_LOG_LEVEL` | Log level for diagnostics on stderr (default `info`). |
| `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>`. |
| `M365_TRANSCRIPT_MAX_BYTES` | Max raw VTT bytes read from Graph per `get_transcript` call. Defense-in-depth bound against the audit-S7 whole-file-buffering pattern. Default `10000000` (10 MB ≈ 8 h+ of speech). |
| `M365_TRANSCRIPT_MAX_CHARS` | Max parsed-text characters returned in a single `get_transcript` response. Long transcripts page via the tool's `offset` + `next_offset` fields. Default `200000` (~2 h of speech). |

> CI enforces that every variable documented in this section is actually
> read from `process.env.<NAME>` somewhere in `src/` — placeholder names
> containing `<>` are skipped. Documenting an env var without wiring it
> up will fail the build (handbook anti-pattern S2).

OAuth scope minimization is per-tool; see the [tool catalog](#tools)
and [`ARCHITECTURE.md`](ARCHITECTURE.md) for the per-tool scope
justifications.

## Binding

The Juvant OS adopter binds this server in `.juvant/config.json`:

```json
{
  "m365-graph": {
    "provider": "microsoft",
    "mcp_server": "npx @juvantlabs/m365-graph-mcp-server@0.4.0",
    "scope": "rw"
  }
}
```

Pinning the version in `mcp_server` keeps installs reproducible. The
canonical inventory entry, with the same pin, is at
[`juvant-os/docs/MCP_INVENTORY.md`](https://github.com/juvantlabs/juvant-os/blob/main/docs/MCP_INVENTORY.md)
— refer to it for the matrix-side bindings (which agents have
`m365-graph:rw` granted by default in the v0 seed) and the wizard
Step 8.5 cross-check semantics.

## Tools

| Tool | Purpose | Input | Output | Required scope |
|---|---|---|---|---|
| `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` |
| `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` |
| `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` |
| `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` |
| `m365-graph:list_calendars` | Lists the user's calendars (primary + group / shared). | `limit?` (1–100, default 50) | `{ count, calendars: [] }` with id / name / color / owner_name / owner_email / is_default / can_edit / can_share. | `Calendars.Read` |
| `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) | `{ calendar_id, window, count, events: [] }` with id / subject / body_preview / start / end / is_all_day / is_online_meeting / location / organizer_name / organizer_email / attendees / webLink. | `Calendars.Read` |
| `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` |
| `m365-graph:get_event` | Fetches full details for a single event — body (capped at 8000 chars), attendees with response statuses, location, recurrence rule, online-meeting join URL when applicable. | `event_id` (required) | event summary + `body` / `body_content_type` / `body_truncated` / `recurrence` / `online_meeting_join_url`. | `Calendars.Read` |
| `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` |
| `m365-graph:create_folder` | Creates a folder in a drive (OneDrive primary by default; SharePoint document library via `drive_id`). Synchronous POST to `/children`. Returns the new folder's item ID so it can be passed as `parent_item_id` to a subsequent `upload_file` / `create_folder` call. `replace` conflict behavior is intentionally not exposed (compose `delete_file` + `create_folder` if truly needed). | `name` (required), `parent_item_id?`, `drive_id?`, `conflict_behavior?` (`fail`/`rename`, default `fail`) | `{ created: { id, name, webUrl, parent_id, child_count } }` | `Files.ReadWrite` |
| `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` |
| `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` |
| `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` |
| `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` |
| `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` |
| `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` |
| `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` |
| `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` |
| `m365-graph:list_meeting_transcripts` | List available transcripts for a Teams meeting identified by its calendar event ID. Transcripts are post-meeting only and require recording to have been enabled by the organizer. | `event_id` (required) | `{ event_id, meeting_id, count, transcripts: [{ id, meeting_id, created_at, end_at }] }` | `Calendars.Read`, `OnlineMeetings.Read` ¹, `OnlineMeetingTranscript.Read.All` ¹ |
| `m365-graph:get_transcript` | Fetch the text content of a Teams meeting transcript. VTT timing markers are stripped; returns clean readable text. Long transcripts page via `offset` + `next_offset`; per-call cap and upstream byte cap configurable via `M365_TRANSCRIPT_MAX_CHARS` / `M365_TRANSCRIPT_MAX_BYTES` (defaults 200 000 chars / 10 MB). | `meeting_id` + `transcript_id` (required, from `list_meeting_transcripts`); `offset?` (0..2_000_000_000), `max_chars?` | `{ meeting_id, transcript_id, offset, char_count, next_offset, total_char_count, truncated, vtt_truncated, transcript }` | `OnlineMeetingTranscript.Read.All` ¹ |

¹ **Admin consent required.** `OnlineMeetings.Read` and `OnlineMeetingTranscript.Read.All` must be granted in the Entra app registration under **API permissions → Add a permission → Microsoft Graph → Delegated → Grant admin consent**. Without admin consent these tools return 403 Forbidden.

That's 4 read + 5 write on files, 5 read + 4 write on calendars, 2 read on meeting transcripts — **20 tools total**. Read tools exercise delegated `Files.Read` + `Calendars.Read`; write tools require `Files.ReadWrite` + `Calendars.ReadWrite`; transcript tools require `OnlineMeetings.Read` + `OnlineMeetingTranscript.Read.All` (all separately granted + admin-consented in the Entra app).

## Local development

The repo expects a `.env.local` file with your tenant's credentials.
Bootstrap from the template:

```bash
cp .env.example .env.local
# then edit .env.local with your M365_TENANT_ID, M365_CLIENT_ID,
# and M365_CLIENT_SECRET — see ARCHITECTURE.md § Authentication
# for the Entra app registration flow.
```

`.env.local` is gitignored.

### One-time OAuth setup

The first time you run the server, you need to complete an OAuth flow
to populate the OS keychain with refresh tokens:

```bash
npm run setup
```

This opens your browser, signs you in to your tenant, captures the
authorization code via a one-shot listener at
`http://localhost:3000/auth/callback`, and persists the resulting
tokens via `@napi-rs/keyring` (macOS Keychain / Linux Secret Service /
Windows Credential Manager). After that, the server uses cached
tokens silently — refreshes as needed via the cached refresh grant.

### Run the MCP server

```bash
npm run dev
```

Listens on stdio. Useful when developing alongside an MCP client like
Claude Code: configure the client to spawn `npm run dev` (or
`tsx --env-file=.env.local src/index.ts`) as its MCP server command.

## Architecture

See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design rationale: scope,
OAuth model with `@azure/msal-node`, token persistence via
`@napi-rs/keyring`, per-tool scope minimization, filesystem sandboxing
for upload/download tools, and async-op polling for `copy` / `move`.

## Contributing

See [`CONTRIBUTING.md`](CONTRIBUTING.md). The repo follows the
[`juvantlabs/handbook`](https://github.com/juvantlabs/handbook)
conventions for MCP server repos.

## Security

See [`SECURITY.md`](SECURITY.md) for the disclosure process. Per the
[handbook security disclosure process](https://github.com/juvantlabs/handbook/blob/main/docs/security/disclosure-process.md),
report vulnerabilities privately via GitHub Security Advisory or
`security@juvant.io`.

## License

[MIT](LICENSE). Copyright (c) 2026 Juvant Srls.

Maintenance

ActivityStale
ResponsivenessResponsive