ckm365
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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
Claude Code via stdio MCP (
ckm365 serve)pydantic-ai agents via direct in-process registration (
ckm365.agent_tools.register— no MCP transport, no HTTP)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 formThe tool functions in
ckm365.tools.mail,.calendar,.watch,.accounts, and.teams— plain typed callables takingCtxfirstThe models they return (
ckm365.models— stdlib dataclasses; pydantic v2 treats them as first-class viaTypeAdapter, which is what the MCP SDK and pydantic-ai do, and a test pins that contract)ckm365.graph.Graph(transport=...)for httpxMockTransportinjection 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.pypassed 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. Nomsgraph-sdk.mcpis an optional extra (ckm365[mcp]) needed only by theckm365 servefront door — programmatic consumers stay unpinned from the MCP SDK's majors. Managed withuv, 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 (withIf-Match) into a FENCED region, sorevise_draftcan rewrite what you wrote without disturbing the quoted history or the signature; never modify delivered message CONTENT.send_draftonly 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 + |
|
| + draft/calendar writes, attachments, triage (read state, flags, move) |
|
| + | + |
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_draftreplaces only the text you wrote.update_draft'sbody_htmlreplaces 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) andrevise_draftrewrites what is inside it. On a draft it did not compose it REFUSES, because guessing is what CKM-48 was — passinsert_if_unfenced=Truefor an Outlook-written draft to insert at the top and fence that.signature_htmlon the profile (profiles.toml) is appended below your text at draft creation —signature=Falseskips 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 needMailboxSettings.Read, a scope this app deliberately never requests.discard_draftthrows 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_attachmentisadd_attachment's inverse, drafts only.exclusions_fileon the profile names a TOML list of addresses not to mail (opted out, bouncing, left). Every draft tool returnsrecipient_warnings, reply-all's inherited recipients included, andsend_draftrefuses anexcludematch unlessoverride_exclusions=True.check_recipientstests a list before you build anything.verify_messageis 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 pathRegister 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-sendApp-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-sendSee 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 |
| Entry point for agent sessions: workflow, conventions, gotchas |
| The server: config, auth, graph client, tools, front doors |
| Usage modes, reference notes, design docs |
| Local ClearKan task board (see |
| Tenant setup (interactive) + live smoke tests |
| Git-ignored scratch (requirements are in |
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…
This server cannot be deployed
Maintenance
Related MCP Connectors
Gmail, Outlook, Drive, OneDrive and calendars for AI agents. Many accounts, one endpoint, audit log.
Email, calendar and contacts for AI agents: Gmail, Microsoft 365 and IMAP accounts behind one API.
Manage Microsoft 365 email, calendar, contacts and inbox rules via the Graph API with OAuth 2.0.
Governed memory and workspace for any AI: tasks, calendar, mail and pages, with per-action consent.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceProvides Claude Desktop and Claude Code with access to Microsoft 365 email and calendar services via the Microsoft Graph API. It enables users to manage emails, search folders, schedule calendar events, and check availability through natural language commands.-
- AlicenseNot gradedqualityCmaintenanceConnects Claude with Microsoft 365 services such as Email, Calendar, Teams, OneDrive, and more through the Microsoft Graph API.73 npm17MIT
- AlicenseAqualityCmaintenanceGives Claude Code access to Outlook Mail, Calendar, and Contacts via Microsoft Graph, with safety-first defaults (no sending, no hard deletes, every mutation logged). Supports multiple Microsoft accounts through a PKCE OAuth flow.21MIT
- AlicenseNot gradedqualityDmaintenanceGives Claude access to Microsoft 365 mail, calendar, files, and Teams chat with per-user identity via On-Behalf-Of flow. Supports reading and drafting content, but sending is deliberately disabled.63 npmMIT