One CLI for Microsoft 365 / Microsoft Graph — the Microsoft-side counterpart to
googleworkspace/cli.
Send mail, post to Teams, manage your calendar, upload to OneDrive, and reach any Graph endpoint — all from a single binary, with OAuth and token storage handled for you.
cargo install mws-cli
mws-cli auth login
mws-cli whoami
mws-cli teams post --team <TEAM-ID> --channel <CHANNEL-ID> --message "hello from mws-cli"- Why
- Install
- Quickstart
- Commands
- Output formats
- Authentication
- Scopes
- Agent / scripting surface
- Shell quoting on Windows
- Building from source
- Project layout
- License
The Microsoft Graph REST API is huge, the OAuth dance is fiddly, and small ergonomic things — pagination, throttling, upload sessions, refresh-on-401, dry-run, output formatting — have to be re-implemented every time. mws-cli makes them defaults.
- Sugar layer — typed commands for the workloads you use daily (mail, drive, calendar, teams).
- Raw escape hatch —
mws-cli raw <METHOD> <path>reaches any Graph v1.0 (or--beta) endpoint, with auth and retries already wired. - Agent-ready — JSON output for non-TTY,
--dry-runon every mutating command, machine-readablemws-cli describeschemas, exit codes, destructive-op guard.
cargo install mws-cliRequires Rust 1.86+. The crate is a single binary named mws-cli.
mws-cli (and most of the Rust ecosystem) targets x86_64-pc-windows-msvc. If you installed Rust through winget install Rustlang.Rust.GNU or any other GNU-target package, cargo install mws-cli will fail with errors like:
error: linker `dlltool.exe` not found
error: failed to compile `windows-sys`
Fix it once and for all by switching to the rustup installer, which manages MSVC properly:
# 1. Remove a GNU-only Rust if you have one
winget uninstall Rustlang.Rust.GNU
# 2. Install the official rustup
winget install --id Rustlang.Rustup
# 3. Open a NEW shell, then:
rustup default stable-x86_64-pc-windows-msvc
# 4. Now this works:
cargo install mws-cliThe first build also needs Visual Studio Build Tools 2022 with the "Desktop development with C++" workload. rustup-init will prompt you to install it automatically if it's missing. Or install it manually:
winget install --id Microsoft.VisualStudio.2022.BuildTools --override "--passive --wait --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.Windows11SDK.22621"Standard rustup install from https://rustup.rs is all you need — no extra toolchain setup.
mws-cli auth login # opens browser (or use --device for SSH/headless)
mws-cli whoami # confirms the signed-in user
mws-cli mail send --to a@x.com --subject hi --body "hello"
mws-cli calendar events # next 7 days
mws-cli drive cp ./notes.txt mws:/Documents/notes.txt
mws-cli teams list| Workload | Commands |
|---|---|
| auth | login (device-code / auth-code+PKCE), list, logout [--all] |
| whoami | profile via Graph /me |
| raw | raw <METHOD> <path> [--body @file|-] [--header k:v]... — any Graph endpoint, with --all paging |
send --to ... --subject ... --body ... [--attachment ...] — small attachments inline, large ones via upload session |
|
| drive | cp <local> mws:/<remote> — single PUT under 4 MiB, chunked upload session above |
| teams | list, channels --team <id>, post --team <id> --channel <id> --message <s> [--html], chats, chat post --chat <id> --message <s>, presence |
| calendar | events [--start --end], create --subject --start --end --attendee... [--online --body --location], find-times --attendee... --duration PT30M, rsvp --event <id> --response accept|decline|tentative, cancel --event <id> |
| describe | machine-readable command/scope catalog for AI agents |
Every mutating command supports --dry-run — prints the prepared HTTP request as JSON and exits 0 without sending. Useful for inspection and agent self-correction.
mws-cli mail send --to a@x.com --subject hi --body "test" --dry-run
mws-cli calendar create --subject Sync --start 2026-05-20T14:00:00Z --end 2026-05-20T15:00:00Z --attendee a@x.com --online --dry-run
mws-cli raw DELETE /me/messages/<ID> --dry-run- TTY stdout → human table by default.
- Pipe / redirect / agent → JSON by default.
- Override anywhere with
--output {json|table|yaml|tsv}(or-o). --allfollows@odata.nextLinkto materialize the entire collection.
mws-cli teams list # table
mws-cli teams list -o json | jq '.value[].displayName'
mws-cli --all raw GET /me/messages -o json # full inbox as a JSON arrayTwo flows ship in M1:
- Auth code + PKCE (default on desktops) — opens a browser, listens on
http://localhost:<random>, exchanges the code, persists tokens. - Device code (
--device) — for SSH, CI, or anywhere without a browser. Print a code, you visitmicrosoft.com/devicelogin.
Tokens are stored AES-256-GCM-encrypted on disk with the encryption key held in the OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service / kwallet). The same shape as gws.
Multiple named accounts:
mws-cli --account work auth login
mws-cli --account personal auth login
mws-cli --account work mail send ...mws-cli auth login requests a single broad consent screen covering the personal-productivity surface — mail, calendar, contacts, OneDrive, OneNote, To Do, Teams chat/presence. No admin-consent (*.All) scopes by default.
| Workload | Scopes |
|---|---|
| Identity | openid, profile, email, offline_access, User.Read |
Mail.ReadWrite, Mail.Send, MailboxSettings.ReadWrite |
|
| Calendar | Calendars.ReadWrite |
| Contacts | Contacts.ReadWrite |
| Files | Files.ReadWrite |
| OneNote | Notes.ReadWrite |
| Tasks | Tasks.ReadWrite |
| People | People.Read |
| Teams | Presence.Read, Chat.ReadWrite, Chat.Create, Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Send |
mws-cli auth login has three flags that compose:
| Flag | Effect |
|---|---|
--scope <SCOPE> (repeatable) |
Add to the default set. Most common use: opt into admin / *.All scopes. |
--exclude-scope <SCOPE> (repeatable) |
Drop a scope from the default set. Use when your tenant blocks specific delegated scopes. |
--no-default-scopes |
Skip DEFAULT_SCOPES entirely. Only the scopes you list with --scope are requested. |
Resolution order: defaults → minus excludes → plus explicit adds. An explicit --scope always wins over --exclude-scope for the same scope name. If the final set is empty, sign-in errors out (Graph rejects empty-scope flows).
# 1. Add admin-consent scopes (opens an admin-approval prompt if needed)
mws-cli auth login --scope Sites.Read.All --scope Directory.Read.All
# 2. Tenant blocks Tasks and Notes — drop them, keep the rest
mws-cli auth login \
--exclude-scope Tasks.ReadWrite \
--exclude-scope Notes.ReadWrite
# 3. Minimum-privilege sign-in — just identity, nothing else
mws-cli auth login --no-default-scopes \
--scope openid \
--scope offline_access \
--scope User.Read
# 4. Custom set tailored to one workload (mail only)
mws-cli auth login --no-default-scopes \
--scope openid --scope offline_access --scope User.Read \
--scope Mail.ReadWrite --scope Mail.SendRe-running mws-cli auth login with different scopes triggers Microsoft's incremental-consent prompt; already-granted scopes are not re-prompted.
These typically need admin approval — opt in only when you know your tenant allows them:
| Scope | Use |
|---|---|
Sites.Read.All / Sites.ReadWrite.All |
SharePoint sites |
Files.Read.All / Files.ReadWrite.All |
All files including shared |
Directory.Read.All |
Read directory (users, groups, devices) |
User.Read.All |
All users in the org |
Group.Read.All / Group.ReadWrite.All |
All groups |
OnlineMeetings.ReadWrite |
Create/manage Teams meetings |
ChannelMessage.Read.All |
Read Teams channel messages (also gated by Microsoft "Protected APIs for Teams") |
Chat.Read.All |
Read all chats in the tenant, not just your own |
Mail.Send.Shared |
Send from a shared mailbox |
Calendars.ReadWrite.Shared |
Read/write shared calendars |
If mws-cli auth login fails with AADSTS65001, AADSTS90094, or "needs admin approval", your tenant requires an administrator to pre-consent on behalf of all users. Generate the URL admin needs to click:
# Default: URL covers DEFAULT_SCOPES — admin clicks once, sign-in works for everyone
mws-cli auth admin-consent
# Add scopes that need admin consent on top of defaults
mws-cli auth admin-consent --scope Sites.Read.All --scope Directory.Read.All
# Only specific scopes — minimum-privilege admin grant
mws-cli auth admin-consent --no-default-scopes --scope Sites.Read.All
# Print-only mode (no browser launch) — handy for sending via Slack/email
mws-cli auth admin-consent --print-only
# Target a specific tenant (recommended over the default 'common')
mws-cli --tenant contoso.onmicrosoft.com auth admin-consentThe URL points to Microsoft's /{tenant}/adminconsent endpoint. When the admin opens it and clicks Accept, consent is recorded tenant-wide. After that any user in the tenant can run mws-cli auth login without per-user consent prompts.
Tenant auto-detection: if you've already signed in once, mws-cli captures your real tenant id from the id_token and uses it automatically — you don't need --tenant. Pass it only if you want to target a different tenant than you signed in to.
Full machine-readable catalog: mws-cli describe scopes.
mws-cli is built to be driven by AI agents and scripts as well as humans.
mws-cli describeprints a JSON catalog of every command, flag, required scope, and example.mws-cli describe <command>returns the schema for one leaf (mws-cli describe calendar create).--dry-runon every write surfaces the exact prepared HTTP request — agents inspect before sending, retry deterministically, or hand-edit the body and replay throughmws-cli raw.- Destructive-op guard —
DELETEandPOST .../delete|/permanentDelete|/revokeGrants|/archiveprompt on TTY; non-TTY callers must pass--yesor exit 4. No silent damage. - Stable exit codes — 0 ok, 1 generic, 2 usage, 3 auth, 4 permission/safety, 5 network, 6 server, 7 throttled, 8 not-found, 9 conflict.
URLs containing $ (OData $top, $select, $filter) need different quoting per shell:
| Shell | Style | Example |
|---|---|---|
cmd.exe |
double-quotes | mws-cli raw GET "/me/messages?$top=3" |
| PowerShell | single-quotes | mws-cli raw GET '/me/messages?$top=3' |
cmd treats ' as a literal character, so mws-cli raw GET '/path?...' would include the quotes in the URL and the request would fail. On macOS / Linux, single-quotes are always fine.
git clone https://github.com/pricelee/mws-cli
cd mws-cli
cargo build --release
./target/release/mws-cli --helpRun the test suite (uses wiremock, no real tenant required):
cargo test --features test-helperssrc/
├── main.rs # binary entry
├── cli.rs # clap derive (root + subcommand args)
├── context.rs errors.rs safety.rs
├── auth/ # OAuth flows + account storage
├── graph/ # Graph HTTP client (auth, retry, paging, upload)
├── keyring/ # AES-256-GCM vault over OS keyring
├── output.rs # JSON/table/YAML/TSV formatters
└── commands/ # one module per workload
├── auth.rs whoami.rs raw.rs describe.rs util.rs
├── mail/ drive/ teams/ calendar/
tests/ # integration tests (assert_cmd + wiremock)
Dual-licensed under either of:
at your option.