Skip to main content
Glama

ckm365 — M365 Graph MCP Server

Minimal, auditable Python MCP server talking directly to Microsoft Graph for mail and calendar, across one or more M365 tenants via named account profiles (never assume a single tenant). Replaces third-party MCP servers and, eventually, the first-generation ClearKan integrations/m365 module.

Consumers

  1. Claude Code via stdio MCP (ckm365 serve)

  2. pydantic-ai agents via direct in-process registration (ckm365.agent_tools.register — no MCP transport, no HTTP)

  3. Plain Python via the supported programmatic API (below) — no MCP, no agent; this is how ClearKan's intake daemon consumes ckm365

All consumers share one thread-safety contract: Ctx/Graph/Auth are safe for concurrent use across threads — one Ctx may serve many threads (e.g. asyncio.to_thread callers). Call Ctx.close() on shutdown, or use with Ctx.create(...) as ctx:, to release the httpx connection pools.

Related MCP server: Office MCP Server

Supported programmatic API

The following surface is supported and covered by SemVer from v1.4.0 onward (renames or signature breaks are a major bump; an import-contract test in tests/test_offline.py fails loudly on drift):

  • ckm365.tools.Ctx — create(), profile(), graph(), set_graph(), target(), require_write(), require_send(), close(), and the context-manager form

  • The tool functions in ckm365.tools.mail, .calendar, .watch, .accounts, and .teams — plain typed callables taking Ctx first

  • The models they return (ckm365.models — stdlib dataclasses; pydantic v2 treats them as first-class via TypeAdapter, which is what the MCP SDK and pydantic-ai do, and a test pins that contract)

  • ckm365.graph.Graph(transport=...) for httpx MockTransport injection in consumer test suites

Everything else (auth.py internals, server.py, underscore-prefixed helpers) may change in any release. Worked example: docs/usage-modes.md → "Programmatic use (no MCP, no agent)". For a Graph endpoint the tools don't cover yet, docs/graph-direct.md is the sanctioned escape hatch — reuse the server's auth/retry plumbing, keep the tier rules, file the gap on the board.

Installing as a dependency: ckm365 @ git+<repo-url>@vX.Y.Z pulls only the core (httpx/msal — nothing else, not even pydantic); add the [mcp] extra only if you run ckm365 serve from that environment.

Principles

  • KISS — the obvious implementation over the clever one, one file per concern, no abstraction before a second caller needs it. Currently no single module exceeds ~400 lines; when tools/mail.py passed 1000 it was split into a package (common/disk/read/attachments/export/ drafts/triage) that still re-exports one import path. There is no hard line limit — readability is the constraint that matters.

  • Two core runtime deps only: httpx, msal. No msgraph-sdk. mcp is an optional extra (ckm365[mcp]) needed only by the ckm365 serve front door — programmatic consumers stay unpinned from the MCP SDK's majors. Managed with uv, hash-locked. New deps need explicit sign-off.

  • Tiered capability, deny by default — see the table below. Sending is never part of the default consent set.

  • Draft-only mail writes — replies/forwards seeded via Graph createReply/createReplyAll/createForward, then PATCHed (with If-Match) into a FENCED region, so revise_draft can rewrite what you wrote without disturbing the quoted history or the signature; never modify delivered message CONTENT. send_draft only sends drafts, and only in the send tier. The triage tools are the one thing that touches delivered mail, and only its metadata (read state, flag, folder, categories) — write tier, never send tier, since nothing leaves the tenant.

  • No secrets in repo — env vars or key material outside git only; token caches are 0600 files under ~/.local/state/ckm365/ with cross-process locking. Logs carry ids and counts, never bodies, subjects, or tokens.

Capability tiers

Server flags

Tools exposed

Delegated scopes requested

(none)

reads + list_accounts + download_attachment + export_message + verify_message

Mail.Read[.Shared], Calendars.Read[.Shared]

--write

+ draft/calendar writes, attachments, triage (read state, flags, move)

*.ReadWrite[.Shared]

--write --enable-send

+ send_draft, attendee-bearing event writes, meeting responses

+ Mail.Send[.Shared]

Send consent is a deliberate per-tenant opt-in (scripts/add-send-scopes.sh) on top of the base consent from scripts/create-app-registration.sh.

download_attachment and export_message are read-tier tools that write local files. Disk confinement is always on: CKM365_DOWNLOAD_ROOT (falling back to CKM365_ATTACH_ROOT) bounds writes; CKM365_ATTACH_ROOT bounds attachment reads. Both default to ~/.local/share/ckm365 (or $XDG_DATA_HOME/ckm365). The state directory, profiles file and its parent directory, and hidden paths below the chosen root are always denied, even with a broad root configured.

The teams preset (read-only discovery: list_teams, list_channels, list_installed_apps) sits outside that ladder on its own consent tier (scripts/add-teams-scopes.sh) — a mail --write flag never implies the ability to enumerate Teams, and the preset has no write tier at all. It is also excluded from --preset all on purpose: name it explicitly (--preset mail,calendar,teams) so it never appears in a session that has not consented to it.

Correspondence in a repo

export_message writes one message to a file, and the extension picks the format: .md for a greppable record or .eml for byte-exact MIME.

The .md record is an Open Knowledge Format v0.2 document — YAML front matter with OKF's type/title/description/resource/tags/generated, mail-specific extension keys beneath it (from, to, cc, mailbox, message ids, the thread keys conversation_id/in_reply_to/references, web_link, body_scope, bulk and auto-reply flags), the body as plain text, then an attachment manifest carrying the ids download_attachment needs. type is Message with channel: email, and resource is the RFC 2392 mid: URI of the Message-ID, so a record in one repo can cite a message held in another. The body is Graph's uniqueBody (the new text alone) unless you pass full_body=True; body_scope says which. The .eml is always the complete chain. It drops into an okf/ repo unmodified, and tags carries the facets worth filtering on (email, inbound/outbound, attachments, bulk, auto-reply). Re-exporting a message is byte-identical, so git shows no diff.

To avoid filing the same message twice, pass dedupe_dir (usually the folder being exported into). The key is the RFC 5322 Message-ID, which is the same in every mailbox's copy and survives a move; the Graph id is neither. A hit writes nothing and returns already_exported with the existing file's path, whether that file is a .md record or an .eml. list_messages rows carry internet_message_id too, so an agent can check its folder before fetching anything.

export_messages does it in bulk: a thread (conversation_id, across folders), a folder (with since/from_address) or a list of ids, oldest first, each named YYYYMMDD_<slug>_<token>.md where the token is 8 hex characters of SHA-1 of the Message-ID, so the same message gets the same name in every run and mailbox. It dedupes the same way, so a rerun writes nothing new; eml=True and attachments=True bring the raw MIME and the files along (inline images too, as inline<N>-<name>, matching the record's [inline N: name] markers), and dry_run=True shows the plan. For finding the mail in the first place, search_messages gets past Graph's silent 275-row search cap by splitting the date range, and list_mail_folders(recursive=True) reaches archive subfolders. For meetings, export_transcript (the meetings preset) takes a join URL and writes every transcript part as the raw .vtt plus an OKF .md of speaker turns.

Folders built before dedupe and OKF v0.2 exist, and every structure change widens the gap. plan_correspondence_migration reads ONE such folder (local only, no Graph, never recurses) and proposes an action per file: none, upgrade (an OKF v0.1 record), backfill-id (a hand-written sidecar whose same-stem .eml carries the Message-ID), pair (a lone .eml with no record), flag-duplicate (same Message-ID, same kind — the spare copy is your git mv), note (not a mail record: a guide or meeting note with no mail beside it, front matter typed other than Email, an unsent draft .eml) or manual (with the reason per file). .msg stays unopened; it is current when its same-stem sidecar carries the Message-ID. A record covering a thread carries its latest message's internet_message_id at top level; nested ids are not read. migrate_correspondence applies upgrade and backfill-id locally, editing front matter only, and pair, locating the message by Message-ID in your mailbox; dry_run=True is the default and still does the lookups, so it can say not-found. It never deletes, never rewrites a body, and a second run changes nothing. Both sit in the read tier and are fenced by CKM365_DOWNLOAD_ROOT like export_message.

Prefer .md for anything agents will search — Exchange base64-encodes body parts, so a raw .eml often contains none of the words in the message (measured: greppable 7/10 for .eml vs 10/10 for .md, at 4–7× the size). .msg is not offered: no Graph endpoint produces it, it is binary, and writing it would need a third-party dependency.

The compose loop

Composing a reply is create_reply_draft → revise_draft → add_attachment / remove_attachment → verify_message → send_draft, with discard_draft for "start again".

What each one exists to stop you hand-rolling:

  • revise_draft replaces only the text you wrote. update_draft's body_html replaces the WHOLE body, which on a reply throws away the quoted history Graph assembled; the tools fence their own region with empty sentinel divs (<div id="ckm365-body-start">, which render as nothing) and revise_draft rewrites what is inside it. On a draft it did not compose it REFUSES, because guessing is what CKM-48 was — pass insert_if_unfenced=True for an Outlook-written draft to insert at the top and fence that.

  • signature_html on the profile (profiles.toml) is appended below your text at draft creation — signature=False skips it for one call — and sits in its own fence, so revising the text above never disturbs it. It is local by design: Outlook's roaming signature would need MailboxSettings.Read, a scope this app deliberately never requests.

  • discard_draft throws away a draft (and only a draft — Graph moves it to Deleted Items, so it is recoverable). Switching a reply to a reply-all is discard + create_reply_draft(reply_all=True): Graph fixes the recipients when it seeds the draft.

  • remove_attachment is add_attachment's inverse, drafts only.

  • exclusions_file on the profile names a TOML list of addresses not to mail (opted out, bouncing, left). Every draft tool returns recipient_warnings, reply-all's inherited recipients included, and send_draft refuses an exclude match unless override_exclusions=True. check_recipients tests a list before you build anything.

  • verify_message is read tier and answers the pre-send questions in one call: recipients, attachment names/sizes/kinds, did the quoted thread survive, is the signature still there, and which non-ASCII characters are in the text you wrote (the smart-quote check — reported, never corrected). Run it on the draft, then on the sent copy.

Setup

uv sync
mkdir -p -m 700 ~/.config/ckm365                          # holds profiles + certs/
cp profiles.example.toml ~/.config/ckm365/profiles.toml   # then edit
uv run ckm365 login <profile>                             # device-code flow
uv run ckm365 doctor [--mcp]                              # config/login/consent; --mcp checks the registration
uv run ckm365 refresh <profile>                           # after new consent: drop cached access tokens
uv run ckm365 audit [--exchange] [--md]                   # who can reach mail; why it fails (docs/access-audit.md)
uv run python scripts/live-smoke.py <profile>             # verify the READ path
uv run python scripts/draft-cycle-smoke.py <profile>      # verify the WRITE path

Register with Claude Code (pick the tier deliberately; --scope user makes it available in every session):

claude mcp add --scope user ckm365 -- uv run --directory /path/to/ckm365 \
  ckm365 serve --preset mail,calendar --write --enable-send

App-only (client_credential) profiles need their credential in the server's own environment. Exporting it from your shell rc reaches the server only if Claude Code was itself started from a shell that sourced that rc, so pass it explicitly instead — only a path and a thumbprint land in .claude.json, never key material:

claude mcp add --scope user ckm365 \
  -e CKM365_TENANT_A_APP_CLIENT_CERT_PATH=$HOME/.config/ckm365/certs/tenant-a-app.key.pem \
  -e CKM365_TENANT_A_APP_CLIENT_CERT_THUMBPRINT=<sha1-thumbprint> \
  -- uv run --directory /path/to/ckm365 \
  ckm365 serve --preset mail,calendar --write --enable-send

See docs/usage-modes.md for the concrete setups: multi-tenant operator with shared mailboxes, personal single-account use, and the planned headless/app-only mode. Joining a tenant that already runs ckm365? docs/onboarding.md is the five-step quickstart.

Layout

Path

Purpose

AGENTS.md

Entry point for agent sessions: workflow, conventions, gotchas

src/ckm365/

The server: config, auth, graph client, tools, front doors

docs/

Usage modes, reference notes, design docs

board/

Local ClearKan task board (see .claude/skills/clearkan-lite/)

scripts/

Tenant setup (interactive) + live smoke tests

tmp/

Git-ignored scratch (requirements are in docs/requirements.md)

License

MIT — see LICENSE. Version in VERSION; history in CHANGELOG.md. The Softeria ms-365-mcp-server was studied as a reference (see docs/reference-notes.md) but no code from it is used here.

Status

Phases 1–2 complete and live-verified on two tenants: 47 tools (mail / calendar / watch / accounts / teams / meetings), three-tier gating, admin CLI, live integration suite, the thread-safety contract and supported programmatic API (SemVer'd; releases are tagged vX.Y.Z for downstream pinning), and app-only mode with Exchange RBAC-only scoping (v1.6.0, incl. the out-of-scope 403 negative test). v2.0.0 slims the core to two deps with dataclass models; v2.1.0 adds read-only Teams discovery; v2.2.0 adds the mail triage slice — batched read-state/flag/move tools and reliable server-side filtering; v2.3.0 puts to/cc on every listing row and adds curated internet headers for bulk/auto-reply detection; v2.4.0 adds read-tier download_attachment (attachment bytes streamed to disk, never into agent context); v2.5.0 adds export_message (a message as a greppable .md record or raw .eml) and an opt-in live send-cycle test covering the send tier end to end; v2.6.0 closes the compose loop (revise_draft, discard_draft, remove_attachment, read-tier verify_message, and per-profile HTML signatures) — offline-verified; its live draft-cycle run is still outstanding; v2.9.0 adds ckm365 audit and Message-ID dedupe on export_message; v2.10.0 adds the correspondence migration planner and apply step; v2.10.1 makes its upgrade front-matter-only, so hand-written sections survive; v2.11.0 adds the planner's note action for files that are not mail; v2.12.0 adds search_messages, subfolders, export_messages, the recipient exclusion list, and thread-aware records (CKM-49); v2.13.0 adds set_categories, export_transcript, inline images in records, the opt-in compose font, ckm365 refresh, doctor --mcp and audit exit codes for CI. Security + simplification reviews done. Next: SharePoint/Teams file sync (CKM-18).

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