Skip to main content
Glama
M365 Graph MCP Server
by juvantlabs

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 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: 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

v0.7.0 — SharePoint document libraries. New read-only tool m365-graph:list_sites finds the SharePoint sites you can access; m365-graph:list_drives takes an optional site_id and returns that site's document libraries. Their drive IDs work with every drive_id parameter (list_items, search_files, download_file, upload_file, create_folder, …). Without site_id, list_drives returns only your OneDrive drives: Microsoft Graph's /me/drives does not include SharePoint libraries in business tenants. No new scope: Sites.ReadWrite.All, already requested, covers site discovery.

v0.6.0 — public-client auth, no client secret. The server now signs in as an OAuth public client (authorization code + PKCE on a loopback redirect, device code fallback). Required configuration is the tenant ID and client ID only, and setup runs straight from npm. Existing users: see § Migrating from v0.5.x.

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 setup after upgrading to acquire them.

See CHANGELOG.md for the full change list.

Related MCP server: Microsoft MCP

Status

Published. v0.4.0 on npm (@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 scaffold mcp-server on 2026-05-03, conforming to the mcp-server.md spec. See CHANGELOG.md for the per-version history.

Install + run

Nothing to clone. Register an Entra app once (§ Setup below), sign in once, then point your MCP client at the npm package:

# 1. One-time sign-in: opens the browser, caches tokens in the OS keychain
M365_TENANT_ID=<tenant-id> M365_CLIENT_ID=<client-id> \
  npx -y @juvantlabs/m365-graph-mcp-server@0.8.0 setup

# 2. Register the server with Claude Code (or any MCP client)
claude mcp add m365-graph -s local \
  -e M365_TENANT_ID=<tenant-id> \
  -e M365_CLIENT_ID=<client-id> \
  -- npx -y @juvantlabs/m365-graph-mcp-server@0.8.0

Requires Node ≥ 20. The server is a public client: it holds no client secret. The tenant ID and client ID are identifiers, not credentials, so they can live in your MCP client configuration. The only sensitive material on disk is the MSAL token cache, kept in the OS keychain.

Environment variables

Required. Both setup and the server read them from the process environment only; no file in the current directory is needed. No client secret: since v0.6.0 a leftover secret variable is ignored and a one-line deprecation warning is printed on stderr (see § Migrating from v0.5.x).

Variable

Purpose

M365_CLIENT_ID

Microsoft Entra application (client) ID for the registered app.

M365_TENANT_ID

Microsoft Entra tenant ID (UUID). The canonical adoption pattern is single-tenant — see 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 and ARCHITECTURE.md for the per-tool scope justifications.

Binding

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

{
  "m365-graph": {
    "provider": "microsoft",
    "mcp_server": "npx -y @juvantlabs/m365-graph-mcp-server@0.8.0",
    "scope": "rw"
  }
}

Pinning the version in mcp_server keeps installs reproducible.

Tools

Tool

Purpose

Input

Output

Required scope

m365-graph:list_sites

Finds SharePoint sites the user can access (keyword search; * = all). If the keyword search finds nothing (Graph search can miss e.g. hyphenated names), falls back to a case-insensitive substring match on name, display name and URL over all accessible sites. Security-trimmed by SharePoint. Pass a returned id to list_drives.

query? (default *, 1–200 chars), limit? (1–100, default 50)

{ query, match, count, sites: [] } (match: search or name_filter) with id / name / display_name / web_url.

Sites.Read.All (covered by Sites.ReadWrite.All)

m365-graph:list_drives

Without site_id: the user's own OneDrive drives (primary + /me/drives). With site_id: the document libraries of that SharePoint site. SharePoint libraries are only returned with site_id.

site_id? (from list_sites, {hostname},{guid},{guid})

without site_id: { primary, accessible: [], note }; with: { site_id, count, drives: [] }. Drives carry id / driveType / name / webUrl / owner.

Files.Read; Sites.Read.All with site_id

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. With format: "pdf", Microsoft Graph converts the file to PDF server-side (no local converter) and the PDF is saved as <name>.pdf in the same sandbox; unsupported source types are rejected with an error listing the supported ones.

item_id (required), drive_id?, format? (pdf)

{ local_path, size_bytes, name, content_type, format }, where format is "pdf" when converted (content_type is then application/pdf) and null for a plain download; when converted also source_size_bytes, source_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 5 read + 5 write on files (including SharePoint site discovery), 5 read + 4 write on calendars, 2 read on meeting transcripts — 21 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).

Setup

1. Register a Microsoft Entra app

  1. Azure Portal → Microsoft Entra ID → App registrations → New registration.

  2. Name: m365-graph-mcp-server (or your team's convention). Supported account types: single-tenant (see ARCHITECTURE.md § Tenancy model).

  3. Redirect URI: platform Public client/native (mobile & desktop), URI http://localhost. Register. (On an existing app: Authentication → Add a platform → Mobile and desktop applications → custom redirect URI http://localhost.) Any port on http://localhost is accepted: setup listens on a free port chosen at run time.

  4. Authentication → Allow public client flows: set to Yes only if you will use setup --device-code (machines without a browser). Leave No otherwise.

  5. API permissions → Add a permission → Microsoft Graph → Delegated: User.Read, Files.ReadWrite, Sites.ReadWrite.All, Calendars.ReadWrite, OnlineMeetings.Read, OnlineMeetingTranscript.Read.All, offline_access. Then Grant admin consent (required for the two transcript scopes, see § Tools).

  6. Certificates & secrets: nothing to create. The server never uses a client secret or certificate.

2. Sign in once (populate the keychain)

export M365_TENANT_ID=<tenant-id>
export M365_CLIENT_ID=<client-id>
npx -y @juvantlabs/m365-graph-mcp-server@0.8.0 setup

setup uses the OAuth 2.0 authorization code flow with PKCE: MSAL starts a one-shot listener on a free port of 127.0.0.1, opens your default browser at Microsoft's sign-in page (the URL is also printed on stderr), receives the code on http://localhost:<port> and redeems it with the PKCE verifier. Tokens are persisted via @napi-rs/keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager), keyed by tenant ID, independent of the current directory. After that, the server uses cached tokens silently and refreshes as needed.

On a machine without a browser, use the device code flow (requires Allow public client flows = Yes on the app):

npx -y @juvantlabs/m365-graph-mcp-server@0.8.0 setup --device-code

Because the port is chosen at run time, setup can run while the sibling m365-mail-mcp-server setup is also in progress.

SharePoint document libraries

list_sites { "query": "finance" }            → site id
list_drives { "site_id": "<site id>" }       → library drive ids
list_items / search_files / download_file / upload_file / create_folder
  { "drive_id": "<library drive id>", ... }

3. Register the server with your MCP client

claude mcp add m365-graph -s local \
  -e M365_TENANT_ID=<tenant-id> \
  -e M365_CLIENT_ID=<client-id> \
  -- npx -y @juvantlabs/m365-graph-mcp-server@0.8.0

Other MCP clients: spawn npx -y @juvantlabs/m365-graph-mcp-server@0.8.0 over stdio with the same environment variables.

Migrating from v0.5.x (client secret → public client)

Up to v0.5.x the server used a confidential client with a client secret and a fixed http://localhost:3000/auth/callback Web redirect. v0.6.0 needs no secret. On your existing app registration:

  1. Authentication → Add a platform → Mobile and desktop applications → custom redirect URI http://localhost.

  2. Set Allow public client flows to Yes only if you will use setup --device-code.

  3. Remove the old Web redirect http://localhost:3000/auth/callback.

  4. Certificates & secrets → delete the client secret.

  5. Remove M365_CLIENT_SECRET from your MCP client configuration and any .env.local (if it is still set, the server ignores it and prints a one-line warning on stderr).

  6. Sign in again: npx -y @juvantlabs/m365-graph-mcp-server@0.8.0 setup. A refresh token cached by v0.5.x was issued to a confidential client and may not be redeemable without the secret; signing in again replaces it in the same keychain entry. Until then, tool calls fail with Silent token acquisition failed: … Re-run … setup.

  7. Update the version pin in your MCP client configuration to 0.8.0 (or later).

If setup fails with AADSTS7000218 (the request body must contain client_assertion or client_secret), the app still treats the redirect as a Web (confidential) client: add the Mobile and desktop applications platform (step 1); for --device-code, also set Allow public client flows = Yes.

Local development

npm install
cp .env.example .env.local   # tenant ID + client ID; gitignored
npm run setup                # = tsx --env-file=.env.local src/index.ts setup
npm run dev                  # stdio server from source

.env.local is only a convenience for the npm run setup / npm run dev scripts in a checkout. The published package never reads it.

Architecture

See 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. The repo follows the juvantlabs/handbook conventions for MCP server repos.

Security

See SECURITY.md for the disclosure process. Per the handbook security disclosure process, report vulnerabilities privately via GitHub Security Advisory or security@juvant.io.

License

MIT. Copyright (c) 2026 Juvant Srls.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers