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.

824 lines (823 loc) • 40.9 kB
import { z } from "zod"; import { isFullPage } from "@notionhq/client"; import { getClient } from "../services/notion.js"; import { clearWrite } from "../services/call-context.js"; import { register } from "./registry.js"; import { tryHandler } from "../utils/handler.js"; import { toErrorEnvelope } from "../utils/error.js"; import { incompleteListProperties, isIncompleteListProperty, PAGE_PROPERTY_REFERENCE_LIMIT, slimPage, slimItem, slimList, } from "../utils/slim.js"; import { DEFAULT_PAGE_LIMIT, paginateAll } from "../utils/paginate.js"; import { parseMarkdownToBlocks } from "../markdown/parse.js"; import { appendBlockTree, nextBlockRequest } from "../utils/write-blocks.js"; import { PARENT_SCHEMA } from "../schema/page.js"; import { ICON_SCHEMA } from "../schema/icon.js"; import { FILE_SCHEMA } from "../schema/file.js"; import { RICH_TEXT_ITEM_REQUEST_SCHEMA } from "../schema/rich-text.js"; import { asSdk, } from "../utils/notion-types.js"; import { PROPERTY_VALUE_SCHEMA } from "../schema/page-properties.js"; import { coerceProperties, hasShorthandValues } from "../schema/property-shorthand.js"; import { dataSourceIdForPage, dataSourceIdForParent, getDataSourceSchema, } from "../services/schema-cache.js"; import { BLOCK_INPUT_SCHEMA } from "../schema/blocks.js"; import { normalizeNotionId, notionId } from "../schema/id.js"; const VERBOSE = z.boolean().optional(); function resolveParent(parent) { if (parent) return parent; const envId = process.env.NOTION_PAGE_ID; if (envId) return { type: "page_id", page_id: normalizeNotionId(envId) }; return undefined; } /** * Turn plain property values into typed ones using the row's data source * schema. The schema is fetched (and cached) only when a plain value is * present, so typed payloads cost no extra request. */ async function prepareProperties(properties, lookup) { let schema; if (hasShorthandValues(properties)) { schema = await lookup(); if (schema && Object.keys(schema).length === 0) schema = undefined; } const coerced = coerceProperties(properties, schema); if (!coerced.ok) return coerced; return { ok: true, properties: coerced.properties, warnings: coerced.warnings }; } function withWarnings(result, warnings) { return warnings.length ? { ...result, warnings } : result; } // ────────────────────────────────────────────────────────────────────────── // create_page // ────────────────────────────────────────────────────────────────────────── const CreatePageParams = z .object({ parent: PARENT_SCHEMA.optional(), title: z.string().optional().describe("Shortcut for setting the title property."), properties: z .record(z.string(), PROPERTY_VALUE_SCHEMA) .optional() .describe("Row properties by name. Under a data_source_id parent plain values are enough: { Status: 'Done', Due: '2026-01-01', Tags: ['a', 'b'], Done: true }."), markdown: z.string().optional().describe("Page body as markdown (GFM: headings, lists, - [ ] checkboxes, tables, code). Parsed server-side."), children: z.array(BLOCK_INPUT_SCHEMA).optional().describe("Structured Notion blocks. Mutually exclusive with markdown."), template: z .object({ type: z.enum(["default", "none", "template_id"]).describe("`template_id` to apply a specific template, `default` for the data source's default template, `none` for no template."), template_id: notionId().optional().describe("Required when type is 'template_id'. Discover IDs via list_data_source_templates."), timezone: z.string().optional().describe("IANA tz (e.g. 'Asia/Hong_Kong') controlling @now/@today resolution inside the template."), }) .optional() .describe("Create the page from a Notion template. The API forbids `markdown`/`children` alongside a template; only data_source_id parents support templates."), icon: ICON_SCHEMA.nullable().optional(), cover: FILE_SCHEMA.nullable().optional(), verbose: VERBOSE, }) .refine((v) => !(v.markdown && v.children), { message: "Pass either `markdown` or `children`, not both.", }) .refine((v) => !(v.template && (v.markdown || v.children)), { message: "Pass either a `template` or body content (`markdown`/`children`), not both — the Notion API rejects children alongside a template.", }) .refine((v) => !(v.template?.type === "template_id" && !v.template.template_id), { message: "template.template_id is required when template.type is 'template_id'.", }); register({ name: "create_page", access: "write", domain: "pages", description: "Create a page under a page or as a row of a data source. Body can be markdown (recommended) or structured blocks; row properties take plain values.", batchable: true, schema: CreatePageParams, example: { parent: { type: "page_id", page_id: "<parent-page-id>" }, title: "My new page", markdown: "## Hello\n\nThis is the body as **markdown**.", }, exampleBatch: { items: [ { parent: { type: "data_source_id", data_source_id: "<data-source-id>" }, title: "Write the report", properties: { Status: "In Progress", Priority: "High", "Due Date": "2026-10-01" }, }, { parent: { type: "data_source_id", data_source_id: "<data-source-id>" }, title: "Review the report", properties: { Status: "Not Started", "Due Date": "2026-10-03" }, }, ], concurrency: 3, }, rollback: async (data) => { if (typeof data !== "object" || data === null) return; const id = data.id; if (!id) return; const notion = await getClient(); await notion.pages.update(asSdk({ page_id: id, in_trash: true })); }, handler: tryHandler(async (params) => { const parent = resolveParent(params.parent); if (!parent) { return { ok: false, error: { code: "missing_parent", message: "No parent specified and NOTION_PAGE_ID is not set.", fix: "Pass `parent: {type:'page_id', page_id:'...'}` or set NOTION_PAGE_ID in the environment.", }, }; } const raw = { ...(params.properties ?? {}) }; if (params.title && !raw.title) { raw.title = { title: [{ type: "text", text: { content: params.title } }], }; } let rowParent = parent; const prepared = await prepareProperties(raw, async () => { if (parent.type === "page_id") return undefined; const dsId = await dataSourceIdForParent(parent); if (!dsId) { throw Object.assign(new Error("multi_source_database"), { code: "multi_source_database" }); } // A database with one data source: create the row in that source. rowParent = { type: "data_source_id", data_source_id: dsId }; return getDataSourceSchema(dsId); }).catch((error) => { if (error.code === "multi_source_database") { return { ok: false, error: { code: "multi_source_database", message: "The database has more than one data source, so a plain property value cannot be typed.", fix: "Pass parent { type: 'data_source_id', data_source_id } — list_data_sources shows the ids — or typed property values.", }, }; } throw error; }); if (!prepared.ok) return prepared; const { properties, warnings } = prepared; // A template and explicit body content are mutually exclusive (API rule). const children = params.template ? undefined : params.markdown ? parseMarkdownToBlocks(params.markdown) : params.children; // The page carries the leading blocks that fit in one request whole; // pages.create returns no block ids, so a block whose children have to // follow later starts the part appended after it. let inline = children ?? []; let tail = []; if (inline.length) { const first = nextBlockRequest(inline); const cut = first.later.length ? first.later[0].index : first.send.length; tail = inline.slice(cut); inline = inline.slice(0, cut); } const notion = await getClient(); const body = { parent: rowParent, properties, ...(inline.length ? { children: inline } : {}), ...(params.template ? { template: params.template } : {}), ...(params.icon !== undefined ? { icon: params.icon } : {}), ...(params.cover !== undefined ? { cover: params.cover } : {}), }; const response = await notion.pages.create(asSdk(body)); if (tail.length) { const failed = await appendBlockTree(notion, response.id, tail).then(() => undefined, (error) => error); if (failed !== undefined) return abandonPartialPage(notion, response.id, failed); } return withWarnings({ ok: true, data: slimPage(response, params.verbose ?? false) }, warnings); }), }); /** * A page whose content ran past Notion's per-request limits is written in * several requests. When a later one fails, trash the page, so the failed * call leaves nothing behind and a retry doesn't make a second copy. */ async function abandonPartialPage(notion, pageId, failure) { const envelope = toErrorEnvelope(failure); const trashed = await notion.pages .update(asSdk({ page_id: pageId, in_trash: true })) .then(() => true, () => false); // A trashed page is the whole of what the call wrote, so a retry is safe. if (trashed) clearWrite(); return { ok: false, error: { ...envelope, message: trashed ? `${envelope.message} The content was too large or too deeply nested for one request, and writing the rest of it after the page was created failed, so the page (${pageId}) was moved to trash.` : `${envelope.message} The content was too large or too deeply nested for one request, and writing the rest of it after the page was created failed. Moving the page to trash failed too, so page ${pageId} is left with part of its content.`, ...(envelope.fix || !trashed ? { fix: [envelope.fix, trashed ? undefined : `Trash page ${pageId} with trash_page before retrying.`] .filter(Boolean) .join(" "), } : {}), }, }; } // ────────────────────────────────────────────────────────────────────────── // set_page_title // ────────────────────────────────────────────────────────────────────────── const SetPageTitleParams = z.object({ page_id: notionId(), title: z.string(), verbose: VERBOSE, }); register({ name: "set_page_title", access: "write", domain: "pages", description: "Rename a page. Updates the page's title property.", batchable: true, schema: SetPageTitleParams, example: { page_id: "<page-id>", title: "New title" }, exampleBatch: { items: [ { page_id: "<page-id-1>", title: "Renamed 1" }, { page_id: "<page-id-2>", title: "Renamed 2" }, ], }, handler: tryHandler(async ({ page_id, title, verbose }) => { const notion = await getClient(); const response = await notion.pages.update(asSdk({ page_id, properties: { title: { title: [{ type: "text", text: { content: title } }] }, }, })); return { ok: true, data: slimPage(response, verbose ?? false) }; }), }); // ────────────────────────────────────────────────────────────────────────── // set_page_property // ────────────────────────────────────────────────────────────────────────── // For the title property, accept a bare string as a shorthand and wrap it into // Notion's rich-text array shape. Avoids forcing the LLM to know the verbose // {title:[{type:"text",text:{content}}]} form for the most common case. function wrapTitleShorthand(input) { if (typeof input !== "object" || input === null) return input; const obj = input; if (obj.name === "title" && typeof obj.value === "string") { return { ...obj, value: { title: [{ type: "text", text: { content: obj.value } }] }, }; } return input; } const SetPagePropertyParams = z.preprocess(wrapTitleShorthand, z.object({ page_id: notionId(), name: z.string().describe("Property name as shown in Notion. `title` always addresses the title property."), value: PROPERTY_VALUE_SCHEMA, verbose: VERBOSE, })); register({ name: "set_page_property", access: "write", domain: "pages", description: "Set one property on one page. A plain value is typed from the data source schema (\"Done\", 42, true, \"2026-01-01\", [\"a\", \"b\"], null to clear). For several properties use set_page_properties.", batchable: true, schema: SetPagePropertyParams, example: { page_id: "<page-id>", name: "Status", value: "In progress", }, exampleBatch: { items: [ { page_id: "<page-id>", name: "Checked", value: true }, { page_id: "<page-id>", name: "Score", value: 42 }, ], }, handler: tryHandler(async ({ page_id, name, value, verbose }) => { const prepared = await prepareProperties({ [name]: value }, () => rowSchemaForPage(page_id)); if (!prepared.ok) return prepared; const notion = await getClient(); const response = await notion.pages.update(asSdk({ page_id, properties: prepared.properties })); return withWarnings({ ok: true, data: slimPage(response, verbose ?? false) }, prepared.warnings); }), }); /** Schema of the data source a page belongs to; undefined for a non-row page. */ async function rowSchemaForPage(pageId) { const dsId = await dataSourceIdForPage(pageId); return dsId ? getDataSourceSchema(dsId) : undefined; } // ────────────────────────────────────────────────────────────────────────── // set_page_properties (plural) // ────────────────────────────────────────────────────────────────────────── function wrapTitleShorthandInProperties(input) { if (typeof input !== "object" || input === null) return input; const obj = input; const props = obj.properties; if (typeof props !== "object" || props === null) return input; const propsObj = props; if (typeof propsObj.title !== "string") return input; return { ...obj, properties: { ...propsObj, title: { title: [{ type: "text", text: { content: propsObj.title } }] }, }, }; } const SetPagePropertiesParams = z.preprocess(wrapTitleShorthandInProperties, z.object({ page_id: notionId(), properties: z .record(z.string(), PROPERTY_VALUE_SCHEMA) .describe("Property name → value, written in one API call. Plain values are typed from the data source schema: { Status: 'Done', Score: 42, Done: true, 'Due Date': '2026-01-01', Tags: ['a', 'b'], Notes: null }."), verbose: VERBOSE, })); register({ name: "set_page_properties", access: "write", domain: "pages", description: "Set several properties on one page in one API call, with plain values typed from the data source schema. Use set_page_property for a single property.", batchable: true, schema: SetPagePropertiesParams, example: { page_id: "<page-id>", properties: { Status: "In progress", Score: 42, Done: false, "Due Date": "2026-10-01", }, }, exampleBatch: { items: [ { page_id: "<page-id-1>", properties: { Status: "Done" } }, { page_id: "<page-id-2>", properties: { Status: "Done" } }, ], }, handler: tryHandler(async ({ page_id, properties, verbose }) => { const prepared = await prepareProperties(properties, () => rowSchemaForPage(page_id)); if (!prepared.ok) return prepared; const notion = await getClient(); const response = await notion.pages.update(asSdk({ page_id, properties: prepared.properties })); return withWarnings({ ok: true, data: slimPage(response, verbose ?? false) }, prepared.warnings); }), }); // ────────────────────────────────────────────────────────────────────────── // archive_page / restore_page // ────────────────────────────────────────────────────────────────────────── const PageIdParams = z.object({ page_id: notionId(), verbose: VERBOSE }); const archivePageHandler = tryHandler(async ({ page_id, verbose }) => { const notion = await getClient(); const response = await notion.pages.update(asSdk({ page_id, in_trash: true })); return { ok: true, data: slimPage(response, verbose ?? false) }; }); register({ name: "archive_page", access: "write", domain: "pages", destructive: true, description: "Move a page to trash. Reversible via restore_page. Alias: trash_page.", batchable: true, schema: PageIdParams, example: { page_id: "<page-id>" }, exampleBatch: { items: [{ page_id: "<page-id-1>" }, { page_id: "<page-id-2>" }] }, handler: archivePageHandler, }); register({ name: "trash_page", access: "write", domain: "pages", destructive: true, description: "Alias of archive_page (2025-09-03 surface naming). Moves a page to trash.", batchable: true, schema: PageIdParams, example: { page_id: "<page-id>" }, exampleBatch: { items: [{ page_id: "<page-id-1>" }, { page_id: "<page-id-2>" }] }, handler: archivePageHandler, }); register({ name: "restore_page", access: "write", domain: "pages", description: "Restore a page previously moved to trash.", batchable: true, schema: PageIdParams, example: { page_id: "<page-id>" }, handler: tryHandler(async ({ page_id, verbose }) => { const notion = await getClient(); const response = await notion.pages.update(asSdk({ page_id, in_trash: false })); return { ok: true, data: slimPage(response, verbose ?? false) }; }), }); // ────────────────────────────────────────────────────────────────────────── // search_pages // ────────────────────────────────────────────────────────────────────────── // Folding combining marks makes accents optional (`café` finds `Cafe`), but it // also merges letters some languages keep apart (`й`/`и`, `ї`/`і`). Ranking // therefore scores both ways: folded first, then with marks kept to break ties. function normalizeTitleSearch(value, foldMarks = true) { const decomposed = value.normalize("NFKD"); return (foldMarks ? decomposed.replace(/\p{M}+/gu, "") : decomposed) .toLowerCase() .replace(/[\p{P}\p{S}]+/gu, " ") .replace(/\s+/g, " ") .trim(); } function titleSearchScore(title, query, foldMarks = true) { if (typeof title !== "string") return 0; const normalizedTitle = normalizeTitleSearch(title, foldMarks); const normalizedQuery = normalizeTitleSearch(query, foldMarks); if (!normalizedTitle || !normalizedQuery) return 0; if (normalizedTitle === normalizedQuery) return 1_000_000; let score = 0; const phraseIndex = normalizedTitle.indexOf(normalizedQuery); if (phraseIndex >= 0) score += 100_000 - Math.min(phraseIndex, 1_000); if (normalizedTitle.startsWith(normalizedQuery)) score += 50_000; const queryTokens = [...new Set(normalizedQuery.split(" ").filter(Boolean))]; const titleTokens = new Set(normalizedTitle.split(" ").filter(Boolean)); const matched = queryTokens.filter((token) => titleTokens.has(token)).length; score += matched * 1_000; return score; } function rankTitleSearch(items, query, getTitle) { return items .map((item, index) => { const title = getTitle(item); return { item, index, score: titleSearchScore(title, query), markScore: titleSearchScore(title, query, false), }; }) .sort((a, b) => b.score - a.score || b.markScore - a.markScore || a.index - b.index) .map(({ item }) => item); } const SearchPagesParams = z.object({ query: z.string().optional().describe("Title substring. Notion search is title-only — it does not search page body content."), sort_direction: z.enum(["ascending", "descending"]).optional(), ranking: z .enum(["notion", "title_relevance"]) .optional() .describe("Result ordering. Omit or use notion to preserve Notion's native order. title_relevance re-ranks up to 100 candidates by normalized title match and is incompatible with sort_direction, start_cursor, and paginate=true."), page_size: z.number().min(1).max(100).optional(), start_cursor: z.string().optional(), paginate: z .boolean() .optional() .describe("Auto-walk all pages and return combined results. Ignores start_cursor when set."), page_limit: z .number() .min(1) .max(1000) .optional() .describe("Max pages to fetch when paginate=true (default 10, ~1000 items with page_size=100)."), verbose: VERBOSE, }).superRefine((params, ctx) => { if (params.ranking !== "title_relevance") return; if (!normalizeTitleSearch(params.query ?? "")) { ctx.addIssue({ code: "custom", path: ["query"], message: "ranking=title_relevance requires a non-empty title query.", }); } for (const field of ["sort_direction", "start_cursor", "paginate"]) { // paginate:false asks for a single page, which is all this mode returns anyway. const conflicts = field === "paginate" ? params.paginate === true : params[field] !== undefined; if (conflicts) { ctx.addIssue({ code: "custom", path: [field], message: `ranking=title_relevance cannot be combined with ${field}. Remove it or use ranking=notion.`, }); } } }); register({ name: "search_pages", access: "read", domain: "pages", description: "Search pages and databases by title. Title-only; does NOT search page body content. Each result carries `object` (page | database | data_source). Default ordering is Notion's native search order. Pass ranking:'title_relevance' to re-rank up to 100 candidates by normalized title relevance. Pass paginate:true to auto-walk all pages.", batchable: false, schema: SearchPagesParams, example: { query: "smoke test", page_size: 10 }, handler: tryHandler(async ({ query, sort_direction, ranking, page_size, start_cursor, paginate, page_limit, verbose }) => { const notion = await getClient(); // Slim pages and databases look alike; the object kind says which id // goes where (a database id is not a valid create_page parent). const slimKind = (item, v) => ({ object: item.object, ...slimItem(item, v), }); const sort = sort_direction ? { sort: { direction: sort_direction, timestamp: "last_edited_time" } } : {}; if (ranking === "title_relevance") { const titleQuery = query.trim(); // Validated by SearchPagesParams. const requested = page_size ?? 10; const response = await notion.search({ query: titleQuery, page_size: 100 }); // Rank raw results using a compact, consistently shaped title, then // convert them to the caller's selected (slim or verbose) representation. const ranked = rankTitleSearch(response.results, titleQuery, (item) => slimItem(item, false).title); return { ok: true, data: { results: ranked.slice(0, requested).map((item) => slimKind(item, verbose ?? false)), ranking: "title_relevance", candidates_considered: response.results.length, source_has_more: response.has_more, }, }; } if (paginate) { const { results, truncated, pages_walked } = await paginateAll(async (cursor) => { const r = await notion.search({ query: query ?? "", ...sort, page_size: page_size ?? 100, start_cursor: cursor, }); return { results: r.results, has_more: r.has_more, next_cursor: r.next_cursor }; }, { limit: page_limit }); return { ok: true, data: { results: results.map((item) => slimKind(item, verbose ?? false)), truncated, pages_walked, }, }; } const response = await notion.search({ query: query ?? "", ...sort, page_size: page_size ?? 10, start_cursor, }); return { ok: true, data: slimList(response, slimKind, verbose ?? false) }; }), }); // ────────────────────────────────────────────────────────────────────────── // get_page // ────────────────────────────────────────────────────────────────────────── const GetPageParams = z.object({ page_id: notionId(), include_properties: z .boolean() .optional() .describe("When true, attach the page's properties as a flattened name → value map. Off by default to keep the slim response tight; use `verbose: true` for the raw Notion SDK shape."), verbose: VERBOSE, }); register({ name: "get_page", access: "read", domain: "pages", description: "Retrieve a page's metadata and (optionally) properties. No body blocks — use get_block_children for those. Pass `include_properties: true` to also return a flattened properties map.", batchable: true, schema: GetPageParams, example: { page_id: "<page-id>" }, handler: tryHandler(async ({ page_id, include_properties, verbose }) => { const notion = await getClient(); let response = await notion.pages.retrieve({ page_id }); if (verbose) { // The raw page is returned as Notion sent it, cut lists included. const cut = incompleteListProperties(response); return withWarnings({ ok: true, data: response }, cut.length ? [`The raw page object holds at most ${PAGE_PROPERTY_REFERENCE_LIMIT} values per relation or people property, and ${propertiesLabel(cut)} may hold more. Call get_page with include_properties:true and without verbose for the full list.`] : []); } let unread = new Map(); if (include_properties && isFullPage(response)) { ({ page: response, unread } = await withFullListProperties(notion, response)); } const capped = include_properties ? incompleteListProperties(response).filter((n) => !unread.has(n)) : []; const keepWhole = "Do not write such a list back with set_page_property: it replaces the whole list and would drop the rest."; return withWarnings({ ok: true, data: slimPage(response, false, include_properties ?? false) }, [ ...(capped.length ? [`The ${propertiesLabel(capped)} ${capped.length === 1 ? "holds" : "hold"} more than ${LIST_PROPERTY_VALUE_LIMIT} values, and only the first ${LIST_PROPERTY_VALUE_LIMIT} were read. ${keepWhole}`] : []), ...[...unread].map(([name, reason]) => `The full list of property "${name}" could not be read (${reason}), so it shows the ${PAGE_PROPERTY_REFERENCE_LIMIT} values the page object carries and may hold more. ${keepWhole}`), ]); }), }); const LIST_PROPERTY_PAGE_LIMIT = DEFAULT_PAGE_LIMIT; const LIST_PROPERTY_PAGE_SIZE = 100; const LIST_PROPERTY_VALUE_LIMIT = LIST_PROPERTY_PAGE_LIMIT * LIST_PROPERTY_PAGE_SIZE; // `property "A"` / `properties "A", "B"` function propertiesLabel(names) { return `${names.length === 1 ? "property" : "properties"} ${names.map((n) => `"${n}"`).join(", ")}`; } /** * Swap in the full value of every relation / people property the page object * cut at 25, read through the property-item endpoint. set_page_property * replaces a whole list, so a read-modify-write on the cut value would delete * every reference past the 25th. A list longer than the walk cap keeps * `has_more: true`, which slimPage reports in `truncated_properties`. * * Each property call is paced and retried on its own by the client * (PacedClient in services/notion.ts). A property whose walk still fails * keeps the page object's values, flagged as cut, and its reason goes in * `unread`, so the page read still succeeds. */ async function withFullListProperties(notion, page) { const properties = { ...page.properties }; const unread = new Map(); for (const [name, prop] of Object.entries(page.properties)) { if (!isIncompleteListProperty(prop)) continue; // The SDK types leave has_more off the page value; Notion sends it on a relation. let full; try { const { values, truncated } = await walkListProperty(notion, page.id, prop.id); full = { ...prop, [prop.type]: values, has_more: truncated }; } catch (error) { const { code, message } = toErrorEnvelope(error); unread.set(name, `${code}: ${message}`); full = { ...prop, has_more: true }; } properties[name] = full; } return { page: { ...page, properties }, unread }; } async function walkListProperty(notion, pageId, propertyId) { const { results, truncated } = await paginateAll(async (cursor) => { const r = await notion.pages.properties.retrieve({ page_id: pageId, property_id: propertyId, page_size: LIST_PROPERTY_PAGE_SIZE, ...(cursor ? { start_cursor: cursor } : {}), }); return r.object === "list" ? { results: r.results, has_more: r.has_more, next_cursor: r.next_cursor } : { results: [r], has_more: false, next_cursor: null }; }, { limit: LIST_PROPERTY_PAGE_LIMIT }); const values = results.flatMap((item) => item.type === "relation" ? [item.relation] : item.type === "people" ? [item.people] : []); return { values, truncated }; } // ────────────────────────────────────────────────────────────────────────── // move_page // ────────────────────────────────────────────────────────────────────────── const MovePageParams = z.object({ page_id: notionId(), parent: PARENT_SCHEMA.describe("New parent (page_id or data_source_id). Same shape as create_page's `parent`."), verbose: VERBOSE, }); register({ name: "move_page", access: "write", domain: "pages", description: "Move a page to a new parent without recreating it. Preserves the page's blocks, properties, and comments. The destination uses `parent` — same field name as create_page.", batchable: true, schema: MovePageParams, example: { page_id: "<page-id>", parent: { type: "page_id", page_id: "<new-parent-id>" }, }, exampleBatch: { items: [ { page_id: "<p1>", parent: { type: "page_id", page_id: "<dest>" } }, { page_id: "<p2>", parent: { type: "page_id", page_id: "<dest>" } }, ], }, handler: tryHandler(async ({ page_id, parent, verbose }) => { if (parent.type !== "page_id" && parent.type !== "data_source_id") { return { ok: false, error: { code: "unsupported_parent", message: `move_page only accepts page_id or data_source_id, received ${parent.type}.`, fix: parent.type === "database_id" ? "Call list_data_sources on the database and pass the resolved data_source_id." : "Set parent.type to 'page_id' or 'data_source_id'.", }, }; } const notion = await getClient(); const response = await notion.pages.move(asSdk({ page_id, parent })); return { ok: true, data: slimPage(response, verbose ?? false) }; }), }); // ────────────────────────────────────────────────────────────────────────── // get_page_markdown // ────────────────────────────────────────────────────────────────────────── const GetPageMarkdownParams = z.object({ page_id: notionId(), }); /** * Notion stops rendering at about 20,000 blocks and renders blocks the * integration can't read as `<unknown>` tags. Either way the markdown is not * the whole page, and writing it back with replace_content would delete what * was left out. */ function incompleteMarkdownWarning(truncated, unknownBlockIds) { if (!truncated && unknownBlockIds.length === 0) return undefined; const why = [ truncated ? "Notion truncated it (pages over about 20,000 blocks)" : undefined, unknownBlockIds.length ? `${unknownBlockIds.length} block(s) could not be loaded and appear as <unknown> tags (not shared with the integration, or past the limit); see unknown_block_ids` : undefined, ].filter(Boolean); return `This markdown is not the whole page: ${why.join("; ")}. Do not replace the whole page with update_page_markdown (replace_content): blocks missing here would be deleted. Edit with insert_content, append_blocks or update_block instead, or pass an unknown block id as page_id to read that block.`; } register({ name: "get_page_markdown", access: "read", domain: "pages", description: "Return a page's body as Notion-rendered markdown. Server-side conversion; round-trips with update_page_markdown. `truncated: true` or a non-empty `unknown_block_ids` means part of the page is missing from the markdown.", batchable: true, schema: GetPageMarkdownParams, example: { page_id: "<page-id>" }, handler: tryHandler(async ({ page_id }) => { const notion = await getClient(); const response = await notion.pages.retrieveMarkdown({ page_id }); const truncated = response.truncated ?? false; const unknownBlockIds = response.unknown_block_ids ?? []; const warning = incompleteMarkdownWarning(truncated, unknownBlockIds); return withWarnings({ ok: true, data: { page_id, markdown: response.markdown ?? "", truncated, unknown_block_ids: unknownBlockIds, }, }, warning ? [warning] : []); }), }); // ────────────────────────────────────────────────────────────────────────── // update_page_markdown // ────────────────────────────────────────────────────────────────────────── const UpdatePageMarkdownParams = z.object({ page_id: notionId(), markdown: z.string().describe("Markdown content. Replaces the existing body by default; with insert_content it is inserted instead."), insert_content: z .object({ position: z.enum(["start", "end"]).describe("Insert at start or end of the page."), after: notionId("block").optional().describe("Block id to insert after (mutually exclusive with position in practice — Notion uses whichever is provided)."), }) .optional(), allow_deleting_content: z .boolean() .optional() .describe("Required true when a replace would remove existing blocks; the API rejects destructive replaces without it."), }); register({ name: "update_page_markdown", access: "write", domain: "pages", description: "Replace (or insert into) a page's body using Notion's server-side markdown renderer. Skip the local remark pipeline. Replacing deletes every block the new markdown leaves out: if get_page_markdown returned `truncated: true` or any `unknown_block_ids`, do not replace the page — use insert_content or block operations.", batchable: true, schema: UpdatePageMarkdownParams, example: { page_id: "<page-id>", markdown: "## Updated heading\n\nNew body.", allow_deleting_content: true, }, handler: tryHandler(async ({ page_id, markdown, insert_content, allow_deleting_content }) => { const notion = await getClient(); const body = insert_content ? { page_id, type: "insert_content", insert_content: { content: markdown, ...(insert_content.after ? { after: insert_content.after } : {}), position: { type: insert_content.position }, }, } : { page_id, type: "replace_content", replace_content: { new_str: markdown, ...(allow_deleting_content !== undefined ? { allow_deleting_content } : {}), }, }; const response = await notion.pages.updateMarkdown(asSdk(body)); return { ok: true, data: { page_id: response.id ?? page_id } }; }), }); void RICH_TEXT_ITEM_REQUEST_SCHEMA;