I Rewrote a OneNote MCP Server in TypeScript — Here's What I Learned About Microsoft Graph Auth

I Rewrote a OneNote MCP Server in TypeScript — Here's What I Learned About Microsoft Graph Auth

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) Enter fullscreen mode Exit fullscreen mode 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']; Enter fullscreen mode Exit fullscreen mode 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', ]; Enter fullscreen mode Exit fullscreen mode 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. 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 jsdom just for HTML-to-text conversion A dependency on node-fetch (unnecessary with Node 18+ native fetch) The deprecated Client.init callback pattern instead of Client.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 — Load/save/normalize access tokens auth.ts — Device-code authentication 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 Enter fullscreen mode Exit fullscreen mode 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 }, ); Enter fullscreen mode Exit fullscreen mode 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.fromStoredToken(); 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 Enter fullscreen mode Exit fullscreen mode 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(/]*>[\s\S]*?/gi, ' ') .replace(//gi, '\n') .replace(//gi, '\n') .replace(/]+>/g, ' ') // ... entity decoding, whitespace collapsing .trim(); } Enter fullscreen mode Exit fullscreen mode 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); } Enter fullscreen mode Exit fullscreen mode 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('fake-token'); const found = await client.findPage('meeting notes'); expect(found?.id).toBe('p1'); Enter fullscreen mode Exit fullscreen mode 34 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 with your Microsoft account npm run verify # confirm it works Enter fullscreen mode Exit fullscreen mode 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"] } } } Enter fullscreen mode Exit fullscreen mode 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-.All scopes — personal accounts silently fail with .All scopes Don't validate token format — personal accounts return non-JWT tokens that are valid Use Client.initWithMiddleware — the callback-based Client.init is deprecated 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: 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 pre-consented public client, so you only need a Microsoft account. Based on azure-onenote-mcp-server by Zubeid Hendricks. Features Device-code authentication — works with both personal Microsoft accounts and work/school (Azure AD) 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 notebooks Typed Zod schemas on every MCP tool for reliable AI integration Unified CLI for scripting and debugging (onenote-cli) Prerequisites Node.js ≥ 18.18 (.nvmrc pins v24.14.1) A Microsoft account with access to OneNote Quick Start git clone https://github.com/danosb/onenote-mcp.git cd onenote-mcp npm install npm run build # compile TypeScript npm… Enter fullscreen mode Exit fullscreen mode PRs welcome.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.