RSS Git Download  Clone
Raw Blame History 14kB 285 lines
#!/usr/bin/env node

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

import { startDeviceCode, pollDeviceCode, isAuthenticated } from './lib/auth.mjs';
import { graphGet, graphPostHtml, graphPatch, graphDelete } from './lib/graph.mjs';
import { extractTables, strikeHtml, parseNumber, buildPatchReplace } from './lib/table.mjs';

const server = new McpServer({
    name: 'onenote-mcp',
    version: '2026.4.1',
    instructions: [
        'P3X OneNote MCP — read & write Microsoft OneNote notebooks via Microsoft Graph.',
        '',
        'First-time setup: set env var P3X_ONENOTE_CLIENT_ID to an Azure public client app ID',
        'with delegated Notes.ReadWrite.All permission, then call `authenticate` and complete',
        'the device code flow in your browser. Tokens are cached under ~/.config/p3x-onenote-mcp/.',
        '',
        'Reading: list_notebooks → list_sections → list_pages → get_page.',
        'Writing: create_page (new) or update_page (patch existing by data-id target).',
        'Tables: use read_table to extract structured rows with cell data-ids, then patch_cell,',
        'strike_row, or calc_total for common table edits (costs, checklists, done markers).',
    ].join('\n'),
});

// ---------- Auth ----------

server.registerTool('authenticate', {
    title: 'Authenticate with Microsoft',
    description: 'Start Microsoft device code flow. Returns a URL and code immediately — visit the URL in a browser, enter the code, sign in. Polling runs in the background; tokens persist to disk once sign-in completes. Call auth_status (or any other tool) to verify.',
    inputSchema: {},
}, async () => {
    const device = await startDeviceCode();
    pollDeviceCode(device.device_code, device.interval ?? 5, device.expires_in ?? 900)
        .catch((err) => console.error('[authenticate] polling error:', err.message));
    const lines = [
        `Code: ${device.user_code}`,
        `Expires in: ${device.expires_in ?? 900}s`,
        '',
        'Open ONE of these URLs and paste the code (the first is what Microsoft returned; the others are fallbacks if that page rejects the code):',
        `  1. ${device.verification_uri}`,
        '  2. https://microsoft.com/devicelogin',
        '  3. https://www.microsoft.com/link',
    ];
    if (device.verification_uri_complete) {
        lines.push('', `One-click (code pre-filled): ${device.verification_uri_complete}`);
    }
    lines.push(
        '',
        'After sign-in + consent, polling finishes in the background and tokens are cached.',
        'Then call auth_status to confirm, or just call list_notebooks.',
    );
    return { content: [{ type: 'text', text: lines.join('\n') }] };
});

server.registerTool('auth_status', {
    title: 'Auth Status',
    description: 'Check whether cached tokens exist (does not validate with Microsoft).',
    inputSchema: {},
}, async () => {
    const ok = await isAuthenticated();
    return { content: [{ type: 'text', text: ok ? 'Authenticated (token cache present).' : 'Not authenticated.' }] };
});

// ---------- Read ----------

server.registerTool('list_notebooks', {
    title: 'List Notebooks',
    description: 'List OneNote notebooks for the signed-in user.',
    inputSchema: {},
}, async () => {
    const res = await graphGet('/me/onenote/notebooks');
    return { content: [{ type: 'text', text: JSON.stringify(res.value ?? res, null, 2) }] };
});

server.registerTool('list_sections', {
    title: 'List Sections',
    description: 'List sections in a notebook (or all sections across notebooks if notebookId omitted).',
    inputSchema: {
        notebookId: z.string().optional().describe('Notebook id from list_notebooks. Omit to list all sections.'),
    },
}, async ({ notebookId }) => {
    const path = notebookId
        ? `/me/onenote/notebooks/${encodeURIComponent(notebookId)}/sections`
        : '/me/onenote/sections';
    const res = await graphGet(path);
    return { content: [{ type: 'text', text: JSON.stringify(res.value ?? res, null, 2) }] };
});

server.registerTool('list_pages', {
    title: 'List Pages',
    description: 'List pages in a section (or all pages if sectionId omitted, up to Graph default page size).',
    inputSchema: {
        sectionId: z.string().optional().describe('Section id from list_sections. Omit to list recent pages.'),
        top: z.number().int().positive().optional().describe('Max pages to return (default 20).'),
    },
}, async ({ sectionId, top }) => {
    const qs = new URLSearchParams();
    if (top) qs.set('$top', String(top));
    const path = sectionId
        ? `/me/onenote/sections/${encodeURIComponent(sectionId)}/pages?${qs}`
        : `/me/onenote/pages?${qs}`;
    const res = await graphGet(path);
    return { content: [{ type: 'text', text: JSON.stringify(res.value ?? res, null, 2) }] };
});

server.registerTool('get_page', {
    title: 'Get Page',
    description: 'Fetch a page\'s HTML content. includeIDs=true adds data-id attributes on every element — required for update_page and table patching.',
    inputSchema: {
        pageId: z.string().describe('Page id from list_pages.'),
        includeIDs: z.boolean().optional().describe('Include data-id on every element (default true). Needed for patching.'),
    },
}, async ({ pageId, includeIDs = true }) => {
    const qs = includeIDs ? '?includeIDs=true' : '';
    const html = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content${qs}`);
    return { content: [{ type: 'text', text: typeof html === 'string' ? html : JSON.stringify(html, null, 2) }] };
});

server.registerTool('search_pages', {
    title: 'Search Pages',
    description: 'Search pages by title substring. Graph $search is limited on OneNote; this filters list_pages server-side via $filter on title.',
    inputSchema: {
        query: z.string().describe('Title substring to match (case-insensitive on server).'),
        top: z.number().int().positive().optional().describe('Max results (default 25).'),
    },
}, async ({ query, top = 25 }) => {
    const qs = new URLSearchParams({
        $filter: `contains(tolower(title),'${query.toLowerCase().replace(/'/g, "''")}')`,
        $top: String(top),
    });
    const res = await graphGet(`/me/onenote/pages?${qs}`);
    return { content: [{ type: 'text', text: JSON.stringify(res.value ?? res, null, 2) }] };
});

// ---------- Write ----------

server.registerTool('create_page', {
    title: 'Create Page',
    description: 'Create a new page in a section with raw HTML body. The body must include <html><head><title>…</title></head><body>…</body></html>.',
    inputSchema: {
        sectionId: z.string().describe('Target section id.'),
        html: z.string().describe('Full HTML document — must contain <title> inside <head> and body content.'),
    },
}, async ({ sectionId, html }) => {
    const res = await graphPostHtml(`/me/onenote/sections/${encodeURIComponent(sectionId)}/pages`, html);
    return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] };
});

server.registerTool('update_page', {
    title: 'Update Page (PATCH)',
    description: 'Patch an existing page by applying a commands array. Each command is {target: data-id or generator, action: "replace"|"append"|"insert"|"prepend", content: html, position?: "before"|"after"}. Use get_page with includeIDs=true to discover data-ids first.',
    inputSchema: {
        pageId: z.string().describe('Page id.'),
        commands: z.array(z.object({
            target: z.string().describe('data-id value, or generator keyword like "body".'),
            action: z.enum(['replace', 'append', 'insert', 'prepend']),
            content: z.string().describe('HTML fragment to apply.'),
            position: z.enum(['before', 'after']).optional(),
        })).describe('Array of PATCH commands.'),
    },
}, async ({ pageId, commands }) => {
    await graphPatch(`/me/onenote/pages/${encodeURIComponent(pageId)}/content`, commands);
    return { content: [{ type: 'text', text: `Patched ${commands.length} command(s) on page ${pageId}.` }] };
});

server.registerTool('delete_page', {
    title: 'Delete Page',
    description: 'Permanently delete a OneNote page. No undo.',
    inputSchema: {
        pageId: z.string().describe('Page id to delete.'),
    },
}, async ({ pageId }) => {
    await graphDelete(`/me/onenote/pages/${encodeURIComponent(pageId)}`);
    return { content: [{ type: 'text', text: `Deleted page ${pageId}.` }] };
});

// ---------- Table helpers ----------

server.registerTool('read_table', {
    title: 'Read Table (structured)',
    description: 'Fetch a page with includeIDs=true and return its tables as structured JSON: tables[].rows[].cells[{id,html,text}]. Use the returned cell/row ids with patch_cell, strike_row, calc_total.',
    inputSchema: {
        pageId: z.string().describe('Page id.'),
    },
}, async ({ pageId }) => {
    const html = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`);
    const tables = extractTables(typeof html === 'string' ? html : '');
    return { content: [{ type: 'text', text: JSON.stringify(tables, null, 2) }] };
});

server.registerTool('patch_cell', {
    title: 'Patch Table Cell',
    description: '⚠️ BROKEN ON PERSONAL ACCOUNTS. Microsoft Graph\'s OneNote PATCH strips <td>/<tr> wrappers on consumer tenants, destroying the cell. Do not use against personal Microsoft accounts (windowslive.com, outlook.com, hotmail.com, live.com). Unknown status on work/school accounts.',
    inputSchema: {
        pageId: z.string(),
        cellId: z.string().describe('id of the <td> (e.g. "td:{guid}{n}").'),
        content: z.string().describe('New inner HTML for the cell (without the <td> tags — they are added automatically).'),
    },
}, async ({ pageId, cellId, content }) => {
    const html = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`);
    const pageHtml = typeof html === 'string' ? html : '';
    const escapedId = cellId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const cellTagRe = new RegExp(`<td([^>]*\\bid="${escapedId}"[^>]*)>`, 'i');
    const match = pageHtml.match(cellTagRe);
    if (!match) throw new Error(`Cell ${cellId} not found on page.`);
    const attrs = match[1];
    const wrapped = `<td${attrs}>${content}</td>`;
    await graphPatch(
        `/me/onenote/pages/${encodeURIComponent(pageId)}/content`,
        [{ target: cellId, action: 'replace', content: wrapped }],
    );
    return { content: [{ type: 'text', text: `Cell ${cellId} content replaced. Preserved attributes: ${attrs.trim()}` }] };
});

server.registerTool('strike_row', {
    title: 'Strike Row',
    description: '⚠️ BROKEN ON PERSONAL ACCOUNTS (same PATCH limitation as patch_cell). Wrap every cell in a row in <s>…</s> (strikethrough). Don\'t use on personal Microsoft accounts.',
    inputSchema: {
        pageId: z.string(),
        rowId: z.string().describe('data-id of the <tr>. Get via read_table.'),
    },
}, async ({ pageId, rowId }) => {
    const html = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`);
    const tables = extractTables(typeof html === 'string' ? html : '');
    const row = tables.flatMap(t => t.rows).find(r => r.id === rowId);
    if (!row) throw new Error(`Row ${rowId} not found on page.`);
    const commands = row.cells.map(c => ({
        target: c.id,
        action: 'replace',
        content: strikeHtml(c.html),
    }));
    await graphPatch(`/me/onenote/pages/${encodeURIComponent(pageId)}/content`, commands);
    return { content: [{ type: 'text', text: `Struck ${commands.length} cell(s) in row ${rowId}.` }] };
});

server.registerTool('calc_total', {
    title: 'Calculate Column Total',
    description: 'Sum numeric values in a given column across the specified rows. Returns the computed sum. ⚠️ The optional writeToCellId parameter uses PATCH which is BROKEN ON PERSONAL ACCOUNTS — omit it on personal Microsoft accounts; the sum is returned regardless. Numbers are parsed tolerantly ("1 234,56 Ft" → 1234.56).',
    inputSchema: {
        pageId: z.string(),
        tableId: z.string().describe('data-id of the <table>. Get via read_table.'),
        columnIndex: z.number().int().nonnegative().describe('0-based column index to sum.'),
        skipRows: z.array(z.number().int().nonnegative()).optional().describe('Row indices to skip (e.g. [0] to skip a header row).'),
        writeToCellId: z.string().optional().describe('If set, the sum is written into this cell\'s HTML as plain text.'),
        formatAs: z.string().optional().describe('printf-like format, e.g. "{n} Ft" or "{n:0.2f}". Default: "{n}".'),
    },
}, async ({ pageId, tableId, columnIndex, skipRows = [], writeToCellId, formatAs = '{n}' }) => {
    const html = await graphGet(`/me/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`);
    const tables = extractTables(typeof html === 'string' ? html : '');
    const table = tables.find(t => t.id === tableId);
    if (!table) throw new Error(`Table ${tableId} not found on page.`);
    let sum = 0;
    const contributions = [];
    for (const [idx, row] of table.rows.entries()) {
        if (skipRows.includes(idx)) continue;
        const cell = row.cells[columnIndex];
        if (!cell) continue;
        const n = parseNumber(cell.text);
        if (!Number.isNaN(n)) { sum += n; contributions.push({ row: idx, value: n, text: cell.text }); }
    }
    const rendered = formatAs
        .replace(/\{n:0\.(\d+)f\}/, (_, d) => sum.toFixed(Number(d)))
        .replace('{n}', String(sum));
    if (writeToCellId) {
        await graphPatch(
            `/me/onenote/pages/${encodeURIComponent(pageId)}/content`,
            buildPatchReplace(writeToCellId, rendered),
        );
    }
    return {
        content: [{
            type: 'text',
            text: JSON.stringify({ sum, rendered, written: !!writeToCellId, contributions }, null, 2),
        }],
    };
});

// ---------- Transport ----------

const transport = new StdioServerTransport();
await server.connect(transport);