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.
414 lines (413 loc) • 16.2 kB
JavaScript
import { isFullBlock, isFullComment, isFullDatabase, isFullDataSource, isFullPage, isFullUser, } from "@notionhq/client";
import { blockFileRef, propertyFileRef, fileRefsEnabled } from "./file-ref.js";
export function extractRichText(rich) {
return rich.map((r) => r.plain_text).join("");
}
export function extractTitle(properties) {
for (const value of Object.values(properties)) {
if (value.type === "title")
return extractRichText(value.title);
}
return undefined;
}
function flattenProperty(prop, ctx) {
switch (prop.type) {
case "title":
return extractRichText(prop.title) || undefined;
case "rich_text":
return extractRichText(prop.rich_text) || undefined;
case "number":
return prop.number ?? undefined;
case "select":
return prop.select?.name ?? undefined;
case "multi_select":
return prop.multi_select.length ? prop.multi_select.map((s) => s.name) : undefined;
case "status":
return prop.status?.name ?? undefined;
case "date": {
if (!prop.date)
return undefined;
const { start, end } = prop.date;
return end ? { start, end } : start;
}
case "people":
return prop.people.length ? prop.people.map((p) => p.id) : undefined;
case "files":
return prop.files.length
? prop.files.map((f, i) => {
if (f.type === "external")
return { name: f.name, url: f.external.url };
// A Notion-hosted file gets a ref when refs are on. An external
// url is already short and stable, so it passes through either way.
const url = fileRefsEnabled() && ctx
? propertyFileRef(ctx.pageId, ctx.property, i)
: f.file.url;
return { name: f.name, url };
})
: undefined;
case "checkbox":
return prop.checkbox;
case "url":
return prop.url ?? undefined;
case "email":
return prop.email ?? undefined;
case "phone_number":
return prop.phone_number ?? undefined;
case "formula": {
const f = prop.formula;
if (f.type === "string")
return f.string ?? undefined;
if (f.type === "number")
return f.number ?? undefined;
if (f.type === "boolean")
return f.boolean ?? undefined;
if (f.type === "date")
return f.date?.start ?? undefined;
return undefined;
}
case "relation":
return prop.relation.length ? prop.relation.map((r) => r.id) : undefined;
case "rollup": {
const r = prop.rollup;
if (r.type === "number")
return r.number ?? undefined;
if (r.type === "date")
return r.date?.start ?? undefined;
if (r.type === "array") {
if (!r.array.length)
return undefined;
const flat = r.array
.map((item) => flattenProperty(item))
.filter((v) => v !== undefined);
return flat.length ? flat : undefined;
}
return undefined;
}
case "created_time":
return prop.created_time;
case "last_edited_time":
return prop.last_edited_time;
case "created_by":
return prop.created_by.id;
case "last_edited_by":
return prop.last_edited_by.id;
case "unique_id": {
const { prefix, number } = prop.unique_id;
if (number == null)
return undefined;
return prefix ? `${prefix}-${number}` : number;
}
case "verification":
return prop.verification?.state ?? undefined;
default:
return undefined;
}
}
// A page object carries at most 25 references in a relation or people value.
// A relation says when it was cut with `has_more`; people carries no flag, so
// a full 25 has to be read as "maybe more".
export const PAGE_PROPERTY_REFERENCE_LIMIT = 25;
export function isIncompleteListProperty(prop) {
if (prop.type !== "relation" && prop.type !== "people")
return false;
const hasMore = prop.has_more;
if (typeof hasMore === "boolean")
return hasMore;
const items = prop.type === "relation" ? prop.relation : prop.people;
return items.length >= PAGE_PROPERTY_REFERENCE_LIMIT;
}
/** Names of the relation / people properties whose value the page object cut short. */
export function incompleteListProperties(page) {
if (!isFullPage(page))
return [];
return Object.entries(page.properties)
.filter(([, prop]) => isIncompleteListProperty(prop))
.map(([name]) => name);
}
/**
* The `warnings` for a list of query rows, slim (`truncated_properties`) or
* raw (`verbose`), ready to spread into a result. Rows are flagged rather than
* completed: that would cost an extra paginated call per cut property per row,
* against a 3 req/s limit, for a read that rarely writes the list back.
* get_page completes them.
*/
export function truncatedRowWarnings(rows) {
const names = new Set();
let count = 0;
for (const row of rows) {
const r = row;
const cut = r.object === "page" ? incompleteListProperties(row) : (r.truncated_properties ?? []);
if (cut.length === 0)
continue;
count += 1;
for (const name of cut)
names.add(name);
}
if (count === 0)
return {};
const list = [...names].map((n) => `"${n}"`).join(", ");
return {
warnings: [
`${count} row(s) may hold more relation or people values than a query returns (at most ${PAGE_PROPERTY_REFERENCE_LIMIT} per property): ${list}. Read the row with get_page include_properties:true for the full list before writing the property back with set_page_property, which replaces the whole list.`,
],
};
}
function flattenProperties(properties, pageId) {
const out = {};
for (const [name, value] of Object.entries(properties)) {
// Skip the title prop — already surfaced as `title`.
if (value.type === "title")
continue;
const flat = flattenProperty(value, { pageId, property: name });
if (flat !== undefined)
out[name] = flat;
}
return out;
}
export function slimPage(page, verbose = false, includeProperties = false) {
if (verbose)
return page;
if (!isFullPage(page))
return { id: page.id };
const base = {
id: page.id,
url: page.url,
title: extractTitle(page.properties),
parent: page.parent,
...(page.in_trash ? { in_trash: true } : {}),
...(page.icon ? { icon: page.icon.type } : {}),
};
if (!includeProperties)
return base;
const props = flattenProperties(page.properties, page.id);
const truncated = incompleteListProperties(page);
return {
...base,
...(Object.keys(props).length ? { properties: props } : {}),
...(truncated.length ? { truncated_properties: truncated } : {}),
};
}
export function slimBlock(block, verbose = false) {
if (verbose)
return block;
if (!isFullBlock(block))
return { id: block.id };
const base = {
id: block.id,
type: block.type,
text: extractBlockText(block),
...(block.has_children ? { has_children: true } : {}),
...(block.in_trash ? { in_trash: true } : {}),
};
if (block.type === "to_do") {
return { ...base, checked: block.to_do.checked };
}
if (block.type === "code") {
return { ...base, language: block.code.language };
}
if (block.type === "table_row") {
return { ...base, cells: block.table_row.cells.map((cell) => extractRichText(cell)) };
}
if (block.type === "table") {
return { ...base, table_width: block.table.table_width };
}
if (block.type === "image") {
const img = block.image;
const url = img.type === "external"
? img.external.url
: fileRefsEnabled()
? blockFileRef(block.id)
: img.file.url;
return { ...base, image: url };
}
return base;
}
// Read a rich-text array that arrived through an `unknown` record. Tolerant of
// items the SDK types don't cover: one malformed entry must not abort a whole
// page read, since slimBlock is mapped across every block in a response.
function richTextField(value) {
if (!Array.isArray(value))
return undefined;
const text = value
.map((item) => item?.plain_text)
.filter((part) => typeof part === "string")
.join("");
return text || undefined;
}
function stringField(value) {
return typeof value === "string" && value ? value : undefined;
}
// table_row keeps its text as an array of per-cell rich-text arrays.
function cellsField(value) {
if (!Array.isArray(value))
return undefined;
const cells = value.map((cell) => richTextField(cell));
if (!cells.some((cell) => cell !== undefined))
return undefined;
return cells.map((cell) => cell ?? "").join(" | ");
}
// A block's text lives under one of several differently named keys depending on
// its subtype, so try them in descending order of how well each describes the
// block. Authored prose first, then the string that identifies the block, then
// its bare url as a last resort — a block should never come back text-free.
// Note `code` carries both a `rich_text` source and a `caption`: the source
// wins, but an empty one has to fall through rather than return "".
// Only top-level `url` strings are read (bookmark, embed, link_preview), where
// the url *is* the content; the nested urls on file-backed media are expiring
// S3 links, and slimBlock surfaces the one that matters (image) on its own.
const TEXT_FIELDS = [
["rich_text", richTextField],
["title", stringField], // child_page, child_database
["expression", stringField], // equation — LaTeX source, not rich text
["caption", richTextField], // bookmark, embed, image, video, pdf, file, audio
["cells", cellsField], // table_row
["name", stringField], // file — its filename, when it carries no caption
["url", stringField], // bookmark, embed, link_preview
];
export function extractBlockText(block) {
// Read the subtype's payload via a structural narrow so we don't have to
// enumerate all 37 block variants.
const inner = block[block.type];
if (typeof inner !== "object" || inner === null)
return undefined;
const fields = inner;
for (const [key, read] of TEXT_FIELDS) {
const text = read(fields[key]);
if (text !== undefined)
return text;
}
return undefined;
}
export function slimDatabase(db, verbose = false) {
if (verbose)
return db;
if (!isFullDatabase(db))
return { id: db.id };
const description = extractRichText(db.description);
return {
id: db.id,
url: db.url,
title: extractRichText(db.title),
...(description ? { description } : {}),
parent: db.parent,
...(db.in_trash ? { in_trash: true } : {}),
...(db.is_inline ? { is_inline: true } : {}),
...(db.is_locked ? { is_locked: true } : {}),
data_sources: db.data_sources.map((s) => ({ id: s.id, name: s.name })),
...(db.icon ? { icon: db.icon.type } : {}),
};
}
const MAX_LISTED_OPTIONS = 30;
/** `select: A | B | C`, `relation → <data_source_id>`, or the bare type. */
export function describePropertyDef(def) {
if (def.type === "select" || def.type === "multi_select" || def.type === "status") {
const options = def[def.type]?.options ?? [];
if (options.length === 0)
return def.type;
const names = options.slice(0, MAX_LISTED_OPTIONS).map((o) => o.name);
const more = options.length - names.length;
return `${def.type}: ${names.join(" | ")}${more > 0 ? ` | +${more} more` : ""}`;
}
if (def.type === "relation") {
const target = def.relation?.data_source_id;
return target ? `relation → ${target}` : "relation";
}
return def.type;
}
export function slimDataSource(ds, verbose = false) {
if (verbose)
return ds;
if (!isFullDataSource(ds))
return { id: ds.id };
const description = extractRichText(ds.description);
return {
id: ds.id,
url: ds.url,
title: extractRichText(ds.title),
...(description ? { description } : {}),
parent: ds.parent,
// name → property-type map, with the option names for select-like
// properties and the target of a relation: exactly what a caller needs to
// write a row or a filter without a verbose:true round-trip.
properties: Object.fromEntries(Object.entries(ds.properties).map(([name, def]) => [name, describePropertyDef(def)])),
...(ds.icon ? { icon: ds.icon.type } : {}),
...(ds.in_trash ? { in_trash: true } : {}),
};
}
// View objects are loosely typed on the SDK surface, so accept `unknown` and
// narrow defensively. Default keeps the high-signal fields (id/name/type and
// any filter/sorts); the bulky type-specific `configuration` is verbose-only.
export function slimView(view, verbose = false) {
if (verbose)
return view;
const v = (view ?? {});
return {
id: v.id,
...(v.name !== undefined ? { name: v.name } : {}),
...(v.type !== undefined ? { type: v.type } : {}),
...(v.filter ? { filter: v.filter } : {}),
...(Array.isArray(v.sorts) && v.sorts.length ? { sorts: v.sorts } : {}),
};
}
export function slimItem(item, verbose = false, includeProperties = false) {
if (verbose)
return item;
if (item.object === "page")
return slimPage(item, verbose, includeProperties);
if (item.object === "database")
return slimDatabase(item, verbose);
return slimDataSource(item, verbose);
}
export function slimUser(user, verbose = false) {
if (verbose)
return user;
if (!isFullUser(user))
return { id: user.id };
const base = {
id: user.id,
type: user.type,
name: user.name,
...(user.avatar_url ? { avatar_url: user.avatar_url } : {}),
};
if (user.type === "person")
return { ...base, email: user.person.email };
if (user.type === "bot") {
const workspaceName = "workspace_name" in user.bot ? user.bot.workspace_name : undefined;
return workspaceName ? { ...base, workspace_name: workspaceName } : base;
}
return base;
}
export function slimFileUpload(fu, verbose = false) {
if (verbose)
return fu;
return {
file_upload_id: fu.id,
...(fu.status ? { status: fu.status } : {}),
...(fu.filename ? { filename: fu.filename } : {}),
...(fu.content_type ? { content_type: fu.content_type } : {}),
...(fu.content_length !== undefined && fu.content_length !== null
? { content_length: fu.content_length }
: {}),
...(fu.expiry_time ? { expiry_time: fu.expiry_time } : {}),
};
}
export function slimComment(comment, verbose = false) {
if (verbose)
return comment;
if (!isFullComment(comment))
return { id: comment.id };
return {
id: comment.id,
parent: comment.parent,
discussion_id: comment.discussion_id,
text: extractRichText(comment.rich_text),
created_by: comment.created_by.id,
};
}
export function slimList(list, slim, verbose = false) {
return {
results: list.results.map((r) => slim(r, verbose)),
has_more: list.has_more ?? false,
next_cursor: list.next_cursor ?? null,
};
}