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.
350 lines (349 loc) • 16.8 kB
JavaScript
import { z } from "zod";
import { getClient } from "../services/notion.js";
import { register } from "./registry.js";
import { tryHandler } from "../utils/handler.js";
import { slimBlock, slimList } from "../utils/slim.js";
import { parseMarkdownToBlocks } from "../markdown/parse.js";
import { asSdk } from "../utils/notion-types.js";
import { appendBlockTree, PartialWriteError } from "../utils/write-blocks.js";
import { toErrorEnvelope } from "../utils/error.js";
import { BLOCK_INPUT_SCHEMA } from "../schema/blocks.js";
import { notionId } from "../schema/id.js";
const VERBOSE = z.boolean().optional();
// ──────────────────────────────────────────────────────────────────────────
// append_blocks
// ──────────────────────────────────────────────────────────────────────────
const AppendBlocksParams = z
.object({
block_id: notionId("block").describe("Parent page ID or block ID to append into."),
markdown: z.string().optional().describe("Content to append, as markdown. Parsed server-side."),
children: z.array(BLOCK_INPUT_SCHEMA).optional().describe("Structured Notion blocks. Mutually exclusive with markdown."),
after: notionId("block").optional().describe("Append immediately after this block ID (legacy ordering)."),
position: z.enum(["start", "end"]).optional().describe("Append at start or end. Preferred over `after`."),
verbose: VERBOSE,
})
.refine((v) => Boolean(v.markdown) !== Boolean(v.children), {
message: "Pass exactly one of `markdown` or `children`.",
})
.refine((v) => !(v.after && v.position), {
message: "Pass at most one of `after` or `position`.",
});
register({
name: "append_blocks",
access: "write",
domain: "blocks",
description: "Append children to a page or block. Use markdown for prose content.",
batchable: true,
schema: AppendBlocksParams,
example: {
block_id: "<page-or-block-id>",
markdown: "## Section\n\n- bullet 1\n- bullet 2",
},
exampleBatch: {
items: [
{ block_id: "<page-id-1>", markdown: "Body 1" },
{ block_id: "<page-id-2>", markdown: "Body 2" },
],
},
handler: tryHandler(async ({ block_id, markdown, children, after, position, verbose }) => {
const blocks = markdown ? parseMarkdownToBlocks(markdown) : (children ?? []);
if (blocks.length === 0) {
return {
ok: false,
error: {
code: "empty_content",
message: "No blocks to append (markdown parsed to empty, or children array is empty).",
},
};
}
const positionArg = position
? { type: position }
: after
? { type: "after_block", after_block: { id: after } }
: undefined;
const notion = await getClient();
let newBlocks;
try {
newBlocks = await appendBlockTree(notion, block_id, blocks, positionArg);
}
catch (error) {
if (!(error instanceof PartialWriteError))
throw error;
return partialAppendError(error, blocks.length);
}
// If Notion returned fewer blocks than were sent (shouldn't happen, but be
// safe), surface only what we got and skip ID emission rather than return
// wrong IDs.
const canMapIds = newBlocks.length >= blocks.length;
if (verbose) {
return {
ok: true,
data: {
appended: blocks.length,
results: newBlocks.map((r) => slimBlock(r, true)),
},
};
}
return {
ok: true,
data: {
appended: blocks.length,
...(canMapIds ? { ids: newBlocks.map((r) => r.id) } : {}),
},
};
}),
});
/**
* Content past Notion's per-request limits goes in several requests, so an
* append can stop part way. Say how far it got and where the rest goes;
* dispatch adds that the call was partly applied.
*/
function partialAppendError(error, total) {
const envelope = toErrorEnvelope(error.failure);
const last = error.written[error.written.length - 1];
return {
ok: false,
error: {
...envelope,
message: `${envelope.message} The content was too large or too deeply nested for one request, so it went in several, and ${error.written.length} of its ${total} top-level blocks were appended before one failed; the last one appended is ${last.id}.`,
fix: `${envelope.fix ? `${envelope.fix} ` : ""}Append the blocks after the first ${error.written.length} with after: "${last.id}".`,
},
};
}
// ──────────────────────────────────────────────────────────────────────────
// get_block
// ──────────────────────────────────────────────────────────────────────────
const GetBlockParams = z.object({
block_id: notionId("block").describe("Block ID to retrieve."),
verbose: VERBOSE,
});
register({
name: "get_block",
access: "read",
domain: "blocks",
description: "Retrieve a single block by ID (metadata + type-specific body). For its children, use get_block_children.",
batchable: true,
schema: GetBlockParams,
example: { block_id: "<block-id>" },
exampleBatch: {
items: [{ block_id: "<block-id-1>" }, { block_id: "<block-id-2>" }],
},
handler: tryHandler(async ({ block_id, verbose }) => {
const notion = await getClient();
const response = await notion.blocks.retrieve({ block_id });
return { ok: true, data: slimBlock(response, verbose ?? false) };
}),
});
// ──────────────────────────────────────────────────────────────────────────
// get_block_children
// ──────────────────────────────────────────────────────────────────────────
const GetBlockChildrenParams = z.object({
block_id: notionId("block").describe("Page ID or block ID. For a page, returns its top-level blocks."),
start_cursor: z.string().optional(),
page_size: z.number().min(1).max(100).optional(),
verbose: VERBOSE,
});
register({
name: "get_block_children",
access: "read",
domain: "blocks",
description: "List child blocks under a page or block, paginated.",
batchable: false,
schema: GetBlockChildrenParams,
example: { block_id: "<page-id>", page_size: 100 },
handler: tryHandler(async ({ block_id, start_cursor, page_size, verbose }) => {
const notion = await getClient();
const response = await notion.blocks.children.list({
block_id,
start_cursor,
page_size: page_size ?? 100,
});
return { ok: true, data: slimList(response, slimBlock, verbose ?? false) };
}),
});
function extractTypedBody(block) {
return { [block.type]: block[block.type] };
}
// When `data` has no explicit `type` and exactly one object-valued key, that
// key is the block type, so {to_do:{checked:true}} works as well as
// {type:"to_do", to_do:{checked:true}}.
function inferDataType(input) {
if (typeof input !== "object" || input === null || Array.isArray(input))
return input;
const obj = input;
if (typeof obj.type === "string")
return obj;
const bodies = Object.keys(obj).filter((k) => typeof obj[k] === "object" && obj[k] !== null && !Array.isArray(obj[k]));
if (bodies.length === 1) {
return { type: bodies[0], ...obj };
}
return obj;
}
// Notion's update endpoint is a partial update: only the fields inside the
// body key change, so {to_do:{checked:true}} leaves the text alone. The
// shape is checked structurally ({type, <type>: {...}}) and Notion validates
// the fields; a typed schema here forced callers to resend rich_text just to
// tick a box.
const UPDATE_DATA_SCHEMA = z.preprocess(inferDataType, z
.looseObject({ type: z.string().describe("Block type; inferred from the sole body key when omitted.") })
.refine((v) => Object.hasOwn(v, v.type) && typeof v[v.type] === "object" && v[v.type] !== null, {
message: "A block update is { type, <type>: { ...fields to change } }, e.g. { to_do: { checked: true } }.",
}));
const UpdateBlockParams = z
.object({
block_id: notionId("block"),
markdown: z
.string()
.optional()
.describe("New content as markdown. Must parse to exactly one block of the existing block's type; `- [x] text` sets a to_do's text and checked state together."),
data: UPDATE_DATA_SCHEMA.optional().describe("Partial update of the block's own fields: `{ <type>: { ...fields } }`, e.g. `{ to_do: { checked: true } }`, `{ code: { language: \"python\" } }`, `{ paragraph: { rich_text: [...] } }`. Fields you leave out are unchanged. The type must match the existing block."),
verbose: VERBOSE,
})
.refine((v) => Boolean(v.markdown) !== Boolean(v.data), {
message: "Pass exactly one of `markdown` or `data`.",
});
register({
name: "update_block",
access: "write",
domain: "blocks",
description: "Update one block. Pass `markdown` for its content (one block; `- [x] text` for a done to_do), or `data` for a partial field update such as { to_do: { checked: true } } or { code: { language: \"python\" } }.",
batchable: true,
schema: UpdateBlockParams,
example: { block_id: "<block-id>", markdown: "Updated paragraph text." },
exampleBatch: {
items: [
{ block_id: "<block-id-1>", markdown: "First update." },
{ block_id: "<block-id-2>", data: { to_do: { checked: true } } },
],
},
handler: tryHandler(async ({ block_id, markdown, data, verbose }) => {
let body;
if (markdown) {
const parsed = parseMarkdownToBlocks(markdown);
if (parsed.length !== 1) {
return {
ok: false,
error: {
code: "markdown_multiblock",
message: `Update requires exactly one block; markdown parsed to ${parsed.length}.`,
fix: "Use append_blocks for multi-block content, or shorten markdown to a single block.",
},
};
}
body = extractTypedBody(parsed[0]);
}
else {
body = extractTypedBody(data);
}
const notion = await getClient();
const response = await notion.blocks.update(asSdk({ block_id, ...body }));
return { ok: true, data: slimBlock(response, verbose ?? false) };
}),
});
// ──────────────────────────────────────────────────────────────────────────
// delete_block
// ──────────────────────────────────────────────────────────────────────────
const DeleteBlockParams = z.object({ block_id: notionId("block"), verbose: VERBOSE });
register({
name: "delete_block",
access: "write",
domain: "blocks",
destructive: true,
description: "Archive (soft-delete) a block.",
batchable: true,
schema: DeleteBlockParams,
example: { block_id: "<block-id>" },
exampleBatch: { items: [{ block_id: "<block-id-1>" }, { block_id: "<block-id-2>" }] },
handler: tryHandler(async ({ block_id, verbose }) => {
const notion = await getClient();
const response = await notion.blocks.delete({ block_id });
return { ok: true, data: slimBlock(response, verbose ?? false) };
}),
});
// ──────────────────────────────────────────────────────────────────────────
// batch_mixed_blocks (power-user escape hatch for atomic-ish mixed ops)
// ──────────────────────────────────────────────────────────────────────────
const MixedOp = z.discriminatedUnion("op", [
z.object({
op: z.literal("append"),
block_id: notionId("block"),
markdown: z.string().optional(),
children: z.array(BLOCK_INPUT_SCHEMA).optional(),
}),
z.object({
op: z.literal("update"),
block_id: notionId("block"),
markdown: z.string().optional(),
data: UPDATE_DATA_SCHEMA.optional(),
}),
z.object({
op: z.literal("delete"),
block_id: notionId("block"),
}),
]);
const BatchMixedBlocksParams = z.object({
operations: z.array(MixedOp).min(1),
verbose: VERBOSE,
});
register({
name: "batch_mixed_blocks",
access: "write",
domain: "blocks",
destructive: true,
description: "Run a sequence of mixed block operations (append/update/delete) in order. Uses a non-standard envelope: { operations: [{ op: \"append\"|\"update\"|\"delete\", ... }] } — NOT the universal { items: [...] } batch envelope. For pure single-op batches, prefer the items[] form on append_blocks / update_block / delete_block.",
batchable: false,
schema: BatchMixedBlocksParams,
example: {
operations: [
{ op: "append", block_id: "<page-id>", markdown: "Header\n" },
{ op: "update", block_id: "<block-id>", markdown: "New text" },
{ op: "delete", block_id: "<other-block-id>" },
],
},
handler: tryHandler(async ({ operations, verbose }) => {
const notion = await getClient();
const results = [];
for (const op of operations) {
if (op.op === "append") {
const blocks = op.markdown
? parseMarkdownToBlocks(op.markdown)
: (op.children ?? []);
if (blocks.length === 0)
throw new Error("append needs markdown or children with at least one block");
const newBlocks = await appendBlockTree(notion, op.block_id, blocks).catch((error) => {
throw error instanceof PartialWriteError ? error.failure : error;
});
const canMapIds = newBlocks.length >= blocks.length;
results.push(verbose
? { op: "append", appended: blocks.length, results: newBlocks.map((x) => slimBlock(x, true)) }
: {
op: "append",
appended: blocks.length,
...(canMapIds ? { ids: newBlocks.map((x) => x.id) } : {}),
});
}
else if (op.op === "update") {
let body;
if (op.markdown) {
const parsed = parseMarkdownToBlocks(op.markdown);
if (parsed.length !== 1)
throw new Error("update markdown must be a single block");
body = extractTypedBody(parsed[0]);
}
else if (op.data) {
body = extractTypedBody(op.data);
}
else {
throw new Error("update requires markdown or data");
}
const r = await notion.blocks.update(asSdk({ block_id: op.block_id, ...body }));
results.push({ op: "update", block: slimBlock(r, verbose ?? false) });
}
else {
const r = await notion.blocks.delete({ block_id: op.block_id });
results.push({ op: "delete", block: slimBlock(r, verbose ?? false) });
}
}
return { ok: true, data: { count: results.length, results } };
}),
});