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)
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',
];
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
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, pathssrc/logger.ts- stderr-only logging (stdout = JSON-RPC)src/token-store.ts- Load/save/normalize access tokenssrc/auth.ts- Device-code authenticationsrc/graph-client.ts- Graph SDK client factorysrc/html.ts- HTMLβtext (dependency-free)src/onenote.ts- OneNoteClient classsrc/mcp-server.ts- MCP server with Zod-typed toolssrc/cli.ts- Unified CLI (replaces 10 scripts)src/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.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
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);
}
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');
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
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
- Use
Client.initWithMiddleware- the callback-basedClient.initis 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: singhAmandeep007/onenote-mcp
PRs welcome.
Comments
No comments yet. Start the discussion.