Peter Niu
Projects / AI Agent Infrastructure

Outlook

An MCP server for personal Outlook — and a worked example of how to wrap any REST API as an AI tool, safely.

active
Updated May 20, 2026
14 min read

I ask Claude what I have on the calendar before lunch, and it answers. I ask it to draft a reply to the vendor email that came in this morning, and a draft is waiting in my Outlook folder when I check. I never give Claude my password. I never let it click “send.”

outlook-mcp is the small Node.js server that makes those two things possible — and a worked example of something more general: how to take a REST API you don’t own and turn it into a tool an AI can use, without handing the AI the keys to your life.

Wrapping an API as an MCP tool is a pattern that shows up everywhere — email, calendars, CRMs, project trackers. Building one end-to-end, including the OAuth flow and the safety boundaries, teaches you what to look for (and what shortcuts to distrust) when evaluating enterprise integration platforms that promise the same thing out of the box.

This walkthrough follows one request — “What’s on my calendar before 2pm?” — from the moment I type it, through the MCP handshake, through OAuth, through Microsoft’s Graph API, and back. Then it shows the design decisions that make the server safe enough to live in my actual inbox: drafts that never send, calendar events that always start tentative, and a webLink on every response so I can confirm what the AI did with my own eyes.

TL;DR

  • How to wrap any REST API as an MCP tool — including the OAuth handshake that lets an AI call the API without ever seeing your password.
  • Why the server exposes only seven tools and physically cannot send email or delete anything — the safety boundaries are in code, not prompts.
  • How the same Node.js server runs locally over stdio (zero auth) and remotely over HTTP (JWT + Lambda authorizer) with no changes to business logic.
  • The “webLink on every response” pattern — how to build verifiability into every AI write operation so a human can confirm what actually happened.

The problem: your email and calendar are locked behind an API

My email and calendar live in Outlook. Claude has no way to reach either by default. Microsoft exposes both through the Graph API — the door any application walks through to read or change anything in a Microsoft 365 account. But “Microsoft exposes an API” and “Claude can use it” are not the same sentence.

Bridging them requires three things:

  • Identity — Microsoft needs to know which account is being read and that the caller is authorized.
  • A translator — Graph speaks REST and JSON; Claude speaks MCP, the protocol AI tools use to call external services.
  • Guardrails — handing an AI read-write access to your inbox without restrictions is a bad idea even when the AI is good.

outlook-mcp is all three at once. It holds the credentials, speaks both protocols, and ships with safety defaults that make destructive operations physically impossible to trigger.

The building blocks

The request crosses several layers before it comes back as an answer. Here’s the cast before tracing the flow.

ComponentRoleWhy this one
Node.js 20Server runtimeNatural fit for REST-heavy code; same process in both stdio and HTTP modes
Microsoft GraphAPI targetThe single REST surface for all of Microsoft 365 — mail, calendar, contacts, Teams — under one base URL
OAuth 2.0 PKCEIdentityStandard way to prove you’re allowed to read your own data without sharing your password
MCP (Model Context Protocol)AI interfaceOpen protocol Claude uses to discover and call external tools by name
stdio (src/index.js)Local transportZero-ceremony pipe between Claude Desktop/Code and the server; no network, no auth
HTTP Streamable (src/server.js)Remote transportAlways-on Railway service reachable from any MCP client
AWS Lambda authorizerRemote authValidates JWTs (issued via magic-link email) or falls back to x-api-key; borrowed from niu-library / niu-news

How a request becomes an answer

Here’s what happens when I ask Claude “what’s on my calendar today?”.

"What's on my calendar before 2pm?" asked inside Claude Code or Claude Desktop Claude picks a tool "list_calendar_events" with start=today, end=2pm sends a JSON-RPC call over MCP MCP outlook-mcp server 1. Validate JWT or x-api-key (HTTP transport only) 2. Load Microsoft refresh token from disk or env 3. Exchange for a fresh access token if needed 4. Translate the MCP call into a Graph request Microsoft Graph GET /me/calendar/events?$filter=... returns JSON for every event in the window Normalize the response Trim Graph's payload, keep start/end/subject/webLink Return JSON small enough to fit Claude's context Claude answers: "You have a 10am and an 11:30."

Steps with a blue stripe are AI-powered. Claude picks the tool, fills in the parameters, and reads the response. Everything in between is plumbing — and the plumbing is what this server is.

The MCP handshake

The first time Claude connects to outlook-mcp, it asks two questions: what tools do you have? and what’s the schema for each? The server answers with a list — list_emails, read_email, search_emails, create_draft, list_calendar_events, get_calendar_event, create_calendar_event — plus a JSON schema for each one describing its arguments and return shape.

That’s the whole magic of MCP. Claude doesn’t need to know anything about Outlook. It reads the tool names and schemas, and from that point they’re part of its vocabulary. Add delete_email tomorrow and Claude discovers it on the next handshake.

The OAuth dance

Before any tool can run, the server needs a Microsoft access token. That’s what the OAuth dance produces.

OAuth solves one awkward problem: letting an app read your email without giving it your password. With a password the app could do anything — change credentials, read everything forever, lock you out. OAuth adds Microsoft as middleman: it holds the password and hands the app a narrower credential — a token scoped only to the permissions you approved (Mail.Read, Mail.ReadWrite, Calendars.Read, Calendars.ReadWrite) and revocable at any time.

The flavor Microsoft uses for personal accounts is PKCE (Proof Key for Code Exchange):

  1. The server opens a Microsoft login page in the browser.
  2. I sign in to Microsoft — not to outlook-mcp. The server never sees my password.
  3. Microsoft asks, in plain English, whether outlook-mcp can read my mail and calendar.
  4. I click yes. Microsoft redirects back to a local server URL with a short-lived authorization code.
  5. The server exchanges that code — plus a secret it generated at step 1 (the “proof key”) — for a refresh token (long-lived) and an access token (short-lived, used to call Graph).
  6. The server stores the refresh token on disk (tokens.json) or in an env var for the deployed instance. All future API calls use it to mint fresh access tokens silently.

PKCE is the right OAuth pattern for personal-scale tools. The “proof key” is a random secret the server generates and holds only for the seconds between steps 1 and 5. If someone intercepts the redirect in step 4, they get a useless authorization code — unredeemable without the proof key. This matters because outlook-mcp is a “public client” that can’t be trusted to hold a long-lived secret. PKCE is OAuth’s answer for that situation — the same flow mobile apps, desktop apps, and CLI tools use every day.

The seven tools, and the principle behind them

The server exposes exactly seven tools. Three read mail, one writes a draft, three handle the calendar. There is no send_email. There is no delete_event. That absence is the point.

AI tools should prepare; humans should confirm. Drafting is reversible — read it, edit it, discard it. Sending is not. The server is designed so every irreversible Outlook operation is unreachable through MCP regardless of what Claude is asked to do. A safety property that depends on the LLM remembering to behave is no safety property at all.

If Claude misunderstands an instruction or hallucinates a recipient, the worst-case outcome is a wrong draft in my drafts folder. That’s a cost I can pay.

Read tools (safe — nothing changes) list_emails read_email search_emails list_calendar_events get_calendar_event Write tools (constrained) create_draft writes to Drafts folder never sends human confirms in Outlook

create_calendar_event defaults to showAs: tentative rejects attendees (v1) human upgrades to busy

Refused (not exposed) send_email delete_email delete_event forward_email invite attendees update_event irreversible or visible-to-others — humans only Every response carries a webLink A direct URL into Outlook so I can verify what was done

Each tool earns the boundary it sits behind:

  • create_draft writes to the Drafts folder via POST /me/messages. Graph’s send endpoint is never called. The draft appears in Outlook like any other — I open it, read it, edit it, decide whether to click send.
  • create_calendar_event writes events with showAs: tentative by default — a soft hold visible to me, not blocking time others see as busy. An LLM that doesn’t pass the override field can’t accidentally mark time as busy. For v1, the tool also rejects any attendees array: the system can put things on my calendar; it cannot send invitations to other people’s.
  • Every response — read or write — includes a webLink: Outlook’s direct URL to the item. One click to verify exactly what the server did.

Include a verification link in every AI write operation. webLink on every response means I can confirm what was created without navigating Outlook manually. It’s a one-line addition to the response normalizer that closes the trust loop: the AI acts, the human verifies, no friction. Any tool that writes to an external system should return a URL to what it wrote.

These aren’t external policies enforced by a wrapper. They’re how the tools are coded.

The dual-transport pattern

The same Node.js code runs in two modes.

Stdio mode (src/index.js) is the local path. The server runs as a child process of Claude Desktop or Claude Code, talking over stdin/stdout. No network, no auth — the host process spawned the server, so trust is implicit. OAuth tokens live in tokens.json on disk. Right model when the user and the server are the same person on the same machine.

HTTP Streamable mode (src/server.js) is the deployed path. The server runs on Railway, reachable from anywhere — which flips the trust model. Any incoming request must prove its identity before the server touches Microsoft. A Lambda authorizer (a small AWS function deployed in its own stack) validates JWTs issued via magic-link email, falling back to a static x-api-key. The Microsoft refresh token lives in OUTLOOK_REFRESH_TOKEN rather than on disk, because Railway containers are ephemeral.

Tool definitions, Graph calls, and safety constraints are shared across both modes. The only difference is how trust is established.

Build local first, add the transport layer second. Stdio gives you a tight feedback loop with zero ceremony — no tokens, no ports, no deploy. Once the business logic is right, wrapping it in an HTTP transport + auth layer is a bounded problem. The alternative (designing for remote auth from day one) couples concern you want to separate: “does this Graph call work?” and “is this request authenticated?” Both matter; answering them together slows you down.

The stack, annotated

LayerServiceWhat it doesNotes
RuntimeNode.js 20Executes the server codeSame code in stdio and HTTP modes
API targetMicrosoft GraphOne REST API for all of Microsoft 365graph.microsoft.com/v1.0/me/*
IdentityOAuth 2.0 PKCEProves the server is allowed to call Graph as meNo client secret — safe for public clients
Token storagetokens.json or env varHolds the long-lived refresh tokenFile for local, env for containers
ProtocolMCP (Model Context Protocol)Standard interface AI tools call7 tools registered
Local transportstdioPipe between Claude and the server processZero auth — implicit trust
Remote transportHTTP Streamable on RailwayReachable from any MCP clientAlways-on web service
Auth (remote)AWS Lambda authorizerValidates JWTs (magic-link email) or x-api-keyBorrowed from niu-library / niu-news
ScopesMail.Read, Mail.ReadWrite, Calendars.Read, Calendars.ReadWriteThe four permissions the app asks Microsoft forAdding one requires re-running the OAuth flow
Safety defaultsDraft-only mail, tentative-only calendar, no attendees, webLink on every responseEncoded in tool implementations, not enforced by promptBoundaries live in code

One Node.js server, two transports, one external API. The omissions — send_email, delete_event, attendee invitations — do as much work as the inclusions.

Why build this yourself?

Microsoft has its own AI assistant. So does every major productivity vendor. You could plug into one without writing a line of code.

The reason outlook-mcp exists is that vendor assistants encode the vendor’s safety judgment, not mine. Their bar for “is this draft ready to send” is calibrated to the median user across millions of accounts. Their decision about what an AI may do to a calendar is one I never participated in.

Building the small thing yourself flips that. The seven tools are the seven I actually want. The four boundaries — drafts only, tentative only, no attendees, always a webLink — are mine, encoded in code I can read and change in an afternoon. Whether the AI can send mail on my behalf isn’t a setting I hope is configured correctly somewhere; it’s a function that doesn’t exist.

That’s the through-line for every project on this site. The interesting layer isn’t the framework or the SDK. It’s the small, specific judgment encoded in code, sitting between an AI that’s good at language and a system powerful enough to do real damage. outlook-mcp is what that judgment looks like when the system in question is my inbox.