DEV Community

Amandeep Singh
Amandeep Singh Subscriber

Posted on

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(/<(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();
}
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:

  1. Use delegated, non-.All scopes — personal accounts silently fail with .All scopes
  2. Don't validate token format — personal accounts return non-JWT tokens that are valid
  3. Use Client.initWithMiddleware — the callback-based Client.init is deprecated
  4. Never write to stdout — MCP uses it for JSON-RPC; all logging goes to stderr
  5. Add Zod schemas to your tools — AI clients need parameter descriptions to use them correctly

The full source:

GitHub logo 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 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.

Top comments (2)

Collapse
 
nazar-boyko profile image
Nazar Boyko

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.

Collapse
 
komo profile image
Reid Marlow

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.