UNPKG

notion-mcp-server

Version:

Notion MCP server with token auth: connect Claude Code, Claude Desktop, Cursor, VS Code, Codex and any MCP client to Notion. Search, create and edit pages, query databases, comment and upload files. Runs headless in CI and agents.

291 lines (290 loc) • 9.1 kB
import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkGfm from "remark-gfm"; const processor = unified().use(remarkParse).use(remarkGfm); export function parseMarkdownToBlocks(markdown) { if (!markdown.trim()) return []; const tree = processor.parse(markdown); return tree.children.flatMap(convertBlock); } function convertBlock(node) { switch (node.type) { case "paragraph": return [paragraphFrom(node)]; case "heading": return [headingFrom(node)]; case "list": return convertList(node); case "blockquote": return convertBlockquote(node); case "code": return [codeFrom(node)]; case "thematicBreak": return [{ type: "divider", divider: {} }]; case "table": return [tableFrom(node)]; default: return []; } } // A GFM table becomes a Notion table block whose children are table_row // blocks. Notion insists that every row has exactly table_width cells and // that the rows travel with the table, so short rows are padded and the // header row is kept as the first row with has_column_header. function tableFrom(node) { const rows = node.children.map((row) => row.children.map((cell) => phrasingToRichText(cell.children))); const width = Math.max(1, ...rows.map((r) => r.length)); const children = rows.map((cells) => { const padded = [...cells]; while (padded.length < width) padded.push([]); return { type: "table_row", table_row: { cells: padded } }; }); return { type: "table", table: { table_width: width, has_column_header: true, has_row_header: false, children, }, }; } function paragraphFrom(node) { if (node.children.length === 1 && node.children[0].type === "image") { return imageFrom(node.children[0]); } const inlineImage = node.children.find((c) => c.type === "image"); if (inlineImage && node.children.length === 1) { return imageFrom(inlineImage); } return { type: "paragraph", paragraph: { rich_text: phrasingToRichText(node.children) }, }; } function headingFrom(node) { const level = Math.min(4, node.depth); const key = `heading_${level}`; return { type: key, [key]: { rich_text: phrasingToRichText(node.children) }, }; } function convertList(node) { const isOrdered = node.ordered === true; const type = isOrdered ? "numbered_list_item" : "bulleted_list_item"; return node.children.map((item) => listItemBlock(item, type)); } function listItemBlock(item, defaultType) { const isToDo = typeof item.checked === "boolean"; const firstParaIdx = item.children.findIndex((c) => c.type === "paragraph"); const head = firstParaIdx >= 0 ? item.children[firstParaIdx] : { type: "paragraph", children: [] }; const richText = phrasingToRichText(head.children); const tail = item.children.filter((_, i) => i !== firstParaIdx); const children = tail.flatMap(convertBlock); if (isToDo) { return { type: "to_do", to_do: { rich_text: richText, checked: item.checked === true, ...(children.length ? { children } : {}), }, }; } return { type: defaultType, [defaultType]: { rich_text: richText, ...(children.length ? { children } : {}), }, }; } function convertBlockquote(node) { const children = node.children.flatMap(convertBlock); if (children.length === 0) return []; const [first, ...rest] = children; if (first.type === "paragraph") { const paragraph = first.paragraph; return [ { type: "quote", quote: { rich_text: paragraph.rich_text, ...(rest.length ? { children: rest } : {}), }, }, ]; } return [ { type: "quote", quote: { rich_text: [], children }, }, ]; } function codeFrom(node) { return { type: "code", code: { rich_text: [ { type: "text", text: { content: node.value }, }, ], language: normalizeLanguage(node.lang), }, }; } function imageFrom(node) { return { type: "image", image: { type: "external", external: { url: node.url }, ...(node.alt ? { caption: [ { type: "text", text: { content: node.alt } }, ], } : {}), }, }; } function phrasingToRichText(nodes, annotations = {}) { const out = []; for (const node of nodes) { pushPhrasing(node, annotations, out); } return mergeAdjacent(out); } function pushPhrasing(node, annotations, out) { switch (node.type) { case "text": out.push(textRT(node.value, annotations)); return; case "strong": for (const c of node.children) { pushPhrasing(c, { ...annotations, bold: true }, out); } return; case "emphasis": for (const c of node.children) { pushPhrasing(c, { ...annotations, italic: true }, out); } return; case "delete": for (const c of node.children) { pushPhrasing(c, { ...annotations, strikethrough: true }, out); } return; case "inlineCode": out.push(textRT(node.value, { ...annotations, code: true })); return; case "link": { const link = node; const inner = []; for (const c of link.children) pushPhrasing(c, annotations, inner); const merged = mergeAdjacent(inner); for (const r of merged) { if (r.type === "text") r.text.link = { url: link.url }; out.push(r); } return; } case "break": out.push(textRT("\n", annotations)); return; default: // Unhandled inline (image inside paragraph already promoted; html, footnote, etc.) return; } } function textRT(content, annotations) { const cleaned = clean(annotations); return { type: "text", text: { content }, ...(Object.keys(cleaned).length ? { annotations: cleaned } : {}), }; } function clean(a) { const out = {}; if (a.bold) out.bold = true; if (a.italic) out.italic = true; if (a.strikethrough) out.strikethrough = true; if (a.underline) out.underline = true; if (a.code) out.code = true; return out; } function mergeAdjacent(items) { const out = []; for (const item of items) { const prev = out[out.length - 1]; if (prev && prev.type === "text" && item.type === "text" && sameAnnotations(prev.annotations, item.annotations) && sameLink(prev.text.link, item.text.link)) { prev.text.content += item.text.content; continue; } out.push(item); } return out; } function sameAnnotations(a, b) { return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {}); } function sameLink(a, b) { return (a?.url ?? null) === (b?.url ?? null); } const KNOWN_LANGUAGES = new Set([ "abap", "arduino", "bash", "basic", "c", "clojure", "coffeescript", "c++", "c#", "css", "dart", "diff", "docker", "elixir", "elm", "erlang", "flow", "fortran", "f#", "gherkin", "glsl", "go", "graphql", "groovy", "haskell", "html", "java", "javascript", "json", "julia", "kotlin", "latex", "less", "lisp", "livescript", "lua", "makefile", "markdown", "markup", "matlab", "mermaid", "nix", "objective-c", "ocaml", "pascal", "perl", "php", "plain text", "powershell", "prolog", "protobuf", "python", "r", "reason", "ruby", "rust", "sass", "scala", "scheme", "scss", "shell", "sql", "swift", "typescript", "vb.net", "verilog", "vhdl", "visual basic", "webassembly", "xml", "yaml", ]); function normalizeLanguage(lang) { if (!lang) return "plain text"; const lower = lang.toLowerCase(); if (KNOWN_LANGUAGES.has(lower)) return lower; const aliases = { js: "javascript", ts: "typescript", py: "python", rb: "ruby", sh: "shell", yml: "yaml", md: "markdown", "c++": "c++", cpp: "c++", cs: "c#", "objc": "objective-c", rs: "rust", tsx: "typescript", jsx: "javascript", }; return aliases[lower] ?? "plain text"; }