If you use Claude, Cursor, or any MCP-compatible AI assistant, you've probably noticed how useful it is when your AI can actually read your notes. I wanted that for OneNote — so I grabbed an existing MCP server, hit a cryptic 401 error, and ended up rewriting the whole thing in TypeScript.
Here's what I learned.
What's an MCP Server?
Model Context Protocol (MCP) is a standard that lets AI assistants call external tools over JSON-RPC. You write a server that exposes tools (like listNotebooks or createPage), and any MCP client — Claude Desktop, Cursor, Claude Code — can invoke them.
The server communicates over stdio: JSON-RPC on stdout, diagnostics on stderr. This is a critical detail that will matter later.
The Starting Point
I started with danosb/onenote-mcp, a JavaScript MCP server that uses Microsoft Graph to access OneNote. It had the right idea — device-code auth so you don't need to register an Azure app — but I hit a wall immediately.
The 401 That Broke Everything
After authenticating with my personal Microsoft account, every Graph API call returned:
HTTP 401 — "The request does not contain a valid authentication token"
(error code 40001)
The token looked fine. Authentication completed successfully. But Graph rejected every request.
The Root Cause: .All Scopes
The original server requested these scopes:
const scopes = ['Notes.Read.All', 'Notes.ReadWrite.All', 'User.Read'];
Those .All scopes are application-level permissions. Personal Microsoft accounts (MSA) cannot consent to them — only Azure AD work/school accounts can. When a personal account tries to use them, Azure does issue a token, but it's a token that Microsoft Graph won't accept.
No error during auth. No warning. Just a silent 401 on every subsequent call.
The Fix
Replace .All scopes with resource-qualified delegated scopes:
export const SCOPES = [
'https://graph.microsoft.com/Notes.Read',
'https://graph.microsoft.com/Notes.ReadWrite',
'https://graph.microsoft.com/User.Read',
'offline_access', // needed for refresh token
'openid', // needed for id_token (username)
];
These are delegated scopes that work for both personal and work/school accounts. They grant access to the signed-in user's own OneNote content, which is exactly what you want for an MCP server. The offline_access scope is critical — without it, you won't get a refresh token and users will have to re-authenticate every hour.
Bonus Gotcha: Non-JWT Tokens
Personal Microsoft accounts return compact (non-JWT) tokens — opaque strings that don't have the three-segment header.payload.signature structure. If your code validates token format by checking for JWTs, it will incorrectly reject perfectly valid personal-account tokens.
The right approach: don't validate token format at all. Let Microsoft Graph be the authority on whether a token is valid.
Why a Full TypeScript Rewrite?
Once I fixed the auth bug, I looked at the codebase and found:
- ~10 standalone JavaScript files doing overlapping things (
simple-onenote.js,list-sections.js,list-pages.js,get-page.js,get-page-content.js,get-all-page-contents.js...) - MCP tools with no parameter schemas — everything came in through
params.random_string - A dependency on
jsdomjust for HTML-to-text conversion - A dependency on
node-fetch(unnecessary with Node 18+ native fetch) - The deprecated
Client.initcallback pattern instead ofClient.initWithMiddleware
So I rewrote it.
The New Architecture
Everything lives in src/ with clear separation:
src/
config.ts — Client ID, tenant, scopes, paths
logger.ts — stderr-only logging (stdout = JSON-RPC)
token-store.ts — Token normalization utilities
auth.ts — OAuth 2.0 device-code auth + silent token refresh
graph-client.ts — Graph SDK client factory
html.ts — HTML→text (dependency-free)
onenote.ts — OneNoteClient class
mcp-server.ts — MCP server with Zod-typed tools
cli.ts — Unified CLI (replaces 10 scripts)
index.ts — Barrel export
Key Design Decisions
1. Zod schemas on every tool
The old server used the SDK's tool() method without schemas, so parameters arrived as params.random_string. The new server defines explicit Zod schemas:
server.tool(
'getPage',
'Get page content by ID or title search.',
{ query: z.string().min(1).describe('Page ID or title substring') },
async ({ query }) => {
// query is typed as string, validated by Zod
},
);
This gives AI clients proper parameter descriptions and type information, so they know exactly what to pass.
2. One client class instead of scattered scripts
OneNoteClient encapsulates all Graph API operations:
const client = OneNoteClient.create();
const notebooks = await client.listNotebooks();
const page = await client.findPage("meeting notes");
const content = await client.getPageContent(page.id);
console.log(content.text); // HTML already converted to plain text
3. Dependency-free HTML→text
OneNote's Graph API returns page content as HTML. The old code used jsdom (a heavy dependency) to parse it. The new htmlToText() function handles it with regex — strip script/style blocks, convert block elements to newlines, decode entities, collapse whitespace:
export function htmlToText(html: string): string {
return html
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|tr|table|ul|ol)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
// ... entity decoding, whitespace collapsing
.trim();
}
For the structured HTML that OneNote returns, this is more than sufficient and eliminates a large dependency.
4. stderr-only logging
This one is subtle but critical. An MCP stdio server uses stdout exclusively for JSON-RPC messages. Any console.log() call corrupts the protocol stream and crashes the connection. All logging goes through a log() helper that writes to stderr:
export function log(...args: unknown[]): void {
console.error('[onenote-mcp]', ...args);
}
5. Direct OAuth 2.0 — no auth library needed
The original code saved a raw access token to a file. Access tokens expire in ~1 hour, so users had to re-authenticate constantly. I initially reached for @azure/msal-node to handle token persistence and silent renewal, but hit a v5 regression where acquireTokenSilent() sent refresh token requests without client_id in the POST body — causing AADSTS900144 errors after the first hour.
The OAuth 2.0 device-code flow is two HTTP calls. Token refresh is one. I replaced the library with ~180 lines of direct fetch calls:
// Silent refresh — the entire implementation
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: cachedRefreshToken,
scope: SCOPES.join(' '),
}).toString(),
});
Tokens are cached in a simple JSON file (chmod 600, gitignored) with access_token, refresh_token, and expires_at. When the access token expires, the server silently refreshes it using the stored refresh token — no user interaction required. Sign in once, never think about it again.
Testing
The test suite uses Vitest with mocked Graph clients — no real API calls needed:
vi.mock('../src/graph-client.js', () => ({
createGraphClient: vi.fn(),
}));
// Mock returns controlled data
mockGraphClient({
'/me/onenote/pages': {
value: [{ id: 'p1', title: 'Meeting Notes 2024' }]
},
});
const client = new OneNoteClient(async () => 'fake-token');
const found = await client.findPage('meeting notes');
expect(found?.id).toBe('p1');
29 tests cover HTML conversion, token handling, and all OneNote client operations.
How to Use It
git clone https://github.com/singhAmandeep007/onenote-mcp.git
cd onenote-mcp
npm install
npm run auth # sign in once — tokens auto-renew after this
npm run verify # confirm it works
Add to Claude Desktop's config — this runs the TypeScript source directly via tsx, no build step needed:
{
"mcpServers": {
"onenote": {
"command": "npx",
"args": ["tsx", "/path/to/onenote-mcp/src/mcp-server.ts"]
}
}
}
Restart Claude Desktop to pick up the new config, then ask: "What's in my OneNote notebooks?"
TL;DR
If you're building an MCP server that talks to Microsoft Graph:
-
Use delegated, non-
.Allscopes — personal accounts silently fail with.Allscopes - Don't validate token format — personal accounts return non-JWT tokens that are valid
-
Request
offline_accessexplicitly — without it you won't get a refresh token, and users re-authenticate every hour - Think twice before reaching for an auth library — the device-code flow is two HTTP calls; token refresh is one. Direct OAuth is fewer lines, zero dependencies, and you can read every line that runs
- Never write to stdout — MCP uses it for JSON-RPC; all logging goes to stderr
- Add Zod schemas to your tools — AI clients need parameter descriptions to use them correctly
The full source:
singhAmandeep007
/
onenote-mcp
MCP server for Microsoft OneNote
OneNote MCP Server
A TypeScript Model Context Protocol (MCP) server that gives AI assistants (Claude, Cursor, etc.) read/write access to your Microsoft OneNote notebooks via the Microsoft Graph API.
Zero Azure setup required. Authentication uses the device-code flow with a first-party Microsoft client ID, so you only need a Microsoft account. Sign in once and tokens renew automatically — you won't be asked to authenticate again.
Fork of azure-onenote-mcp-server by Zubeid Hendricks — rewritten in TypeScript with proper auth, typed tools, and a unified CLI.
Features
- One-time authentication — sign in once via device-code flow; tokens silently refresh using a stored refresh token (access tokens expire hourly, but this is invisible to you)
- Works with both personal Microsoft accounts and work/school (Entra ID) accounts
- List notebooks, sections, and pages
- Read page content as plain text (HTML is stripped automatically)
- Create pages with HTML content
- Search pages by title across all…
PRs welcome.
Top comments (10)
Useful rewrite, especially the stdout and opaque-token points. One terminology correction:
.Alldoes not universally mean application-only in Microsoft Graph.Notes.Read.AllandNotes.ReadWrite.Allhave delegated forms for work/school accounts as well as application forms; the key limitation is that personal Microsoft accounts support the narrower delegatedNotes.Read/Notes.ReadWrite, not those.Alldelegated permissions. So I’d diagnose this from the app’s supported account types, requested scopes, token response metadata and the endpoint’s permission table—not the suffix alone. Your least-privilege fix is still the right outcome. Microsoft also explicitly says clients should treat Graph access tokens as opaque and let the resource validate them: learn.microsoft.com/en-us/entra/id...Microsoft Graph OAuth flows are notoriously tricky, especially when dealing with delegated permissions versus application permissions for background tasks in an MCP server. I always recommend setting up a robust token refresh mechanism early on, since Graph access tokens expire in an hour and silent token acquisition can silently fail if the scopes drift. I actually had to untangle a very similar auth scope issue when integrating Microsoft login into our Next.js SaaS boilerplate, which eventually led us to build PubliFlow with pre-configured OAuth flows to save others the headache.
What happens when the stored access token expires? Graph access tokens only last about an hour, so I'm wondering whether the device code flow here also saves a refresh token and renews quietly, or whether you re-run npm run auth every session. That detail decides whether this is set up once or a daily ritual.
right now it's a daily ritual. The access token lasts about an hour and there's no silent renewal, we can re-run npm run auth when it expires. The device-code flow does hand back a refresh token that could be used to renew quietly, I just haven't wired that up yet.
I have addressed with silent token auto-renewal. Check out the latest. Thanks.
The regex htmlToText is the one decision I'd revisit — not because it's wrong for today's OneNote HTML, but because you've moved the fragility from a dependency you can pin to input you don't control. OneNote pages can carry tables, nested lists, and
<div>s with inline styles, and the Graph API's HTML isn't a documented contract — it changes. The specific failure mode that'll bite an MCP consumer: your</p>/</div>→newline rule doesn't touch opening tags, so a page authored as a single styled<div>wrapping everything collapses into one blob with the structure gone, and the model silently loses the list/heading semantics it was relying on. That's worse than a heavy dependency, because it fails as degraded output, not a crash.If jsdom felt too heavy, the middle ground is a streaming parser like htmlparser2 (what jsdom sits on anyway) — you get real tag-boundary handling for a fraction of the weight, without hand-maintaining regex against an undocumented HTML source. Worth at least a golden-file test that pins a real exported page's HTML so you find out when Graph's markup shifts, rather than the model finding out for you.
Thanks, I will have a look into it.
The real line with Graph auth is refresh-token handling. Device code flow is fine for setup, but if the MCP server can't renew quietly, every "just use my notes" workflow eventually turns into an auth chore.
I'd also keep token storage deliberately boring: OS keychain or a locked-down local file, never inside the MCP config that gets copied into dotfiles or repos.
Yeah, you're right, refresh token handling is the right next step. Right now it's rerunning "npm run auth" approach, which is fine for playing around but would get irritating in a daily workflow. The device-code flow does return a refresh token, I just didn't persist it yet.
Good call on token storage too. OS keychain would be nicer but adds platform-specific dependencies, might be worth it though if this gets more users.
Microsoft Graph authentication can be a massive headache, especially when dealing with token expiration in long-running MCP server processes. I found that implementing a robust silent token refresh mechanism using MSAL is crucial, otherwise your AI assistant just stops working mid-conversation. Did you run into any issues with delegated versus application permissions when setting up the OneNote scopes? Handling the interactive login flow for a background MCP server was the trickiest part of my own Graph integration.