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.

233 lines (232 loc) • 11.1 kB
import { z } from "zod"; import { isFullDatabase, isFullDataSource } from "@notionhq/client"; import { getClient } from "../services/notion.js"; import { register } from "./registry.js"; import { tryHandler } from "../utils/handler.js"; import { slimDataSource } from "../utils/slim.js"; import { DATABASE_PROPERTY_SCHEMA } from "../schema/database.js"; import { asSdk } from "../utils/notion-types.js"; import { notionId } from "../schema/id.js"; import { forgetDataSourceSchema, rememberDataSourceSchema } from "../services/schema-cache.js"; import { destructiveGroupBlocked } from "./access.js"; const VERBOSE = z.boolean().optional(); const ListDataSourcesParams = z.object({ database_id: notionId().describe("Database ID to list data sources for."), verbose: VERBOSE, }); register({ name: "list_data_sources", access: "read", domain: "data_sources", description: "List data sources under a database. Use this before query_database when targeting multi-source databases.", batchable: false, schema: ListDataSourcesParams, example: { database_id: "<database-id>" }, handler: tryHandler(async ({ database_id, verbose }) => { const notion = await getClient(); const db = await notion.databases.retrieve({ database_id }); const sources = isFullDatabase(db) ? db.data_sources : []; return { ok: true, data: verbose ? { database_id, data_sources: sources } : { database_id, data_sources: sources.map((s) => ({ id: s.id, name: s.name })), }, }; }), }); const GetDataSourceParams = z.object({ data_source_id: notionId(), verbose: VERBOSE, }); register({ name: "get_data_source", access: "read", domain: "data_sources", description: "Retrieve a single data source's schema (its property definitions and parent database).", batchable: true, schema: GetDataSourceParams, example: { data_source_id: "<data-source-id>" }, exampleBatch: { items: [{ data_source_id: "<ds-1>" }, { data_source_id: "<ds-2>" }] }, handler: tryHandler(async ({ data_source_id, verbose }) => { const notion = await getClient(); const ds = await notion.dataSources.retrieve({ data_source_id }); return { ok: true, data: slimDataSource(ds, verbose ?? false) }; }), }); const ListDataSourceTemplatesParams = z.object({ data_source_id: notionId().describe("Data source ID to list templates for."), name: z.string().optional().describe("Case-insensitive substring filter on template name."), start_cursor: z.string().optional(), page_size: z.number().int().min(1).max(100).optional(), }); register({ name: "list_data_source_templates", access: "read", domain: "data_sources", description: "List the page templates available for a data source. Returns {id, name, is_default} per template. Pass a returned id as template.template_id to create_page to apply it.", batchable: false, schema: ListDataSourceTemplatesParams, example: { data_source_id: "<data-source-id>" }, handler: tryHandler(async ({ data_source_id, name, start_cursor, page_size }) => { const notion = await getClient(); const res = await notion.dataSources.listTemplates({ data_source_id, ...(name !== undefined ? { name } : {}), ...(start_cursor !== undefined ? { start_cursor } : {}), ...(page_size !== undefined ? { page_size } : {}), }); return { ok: true, data: { data_source_id, templates: res.templates }, }; }), }); // ────────────────────────────────────────────────────────────────────────── // update_data_source / delete_data_source // ────────────────────────────────────────────────────────────────────────── const UpdateDataSourceParams = z.object({ data_source_id: notionId(), title: z.array(z.unknown()).optional().describe("Rich text array for the data source title."), // dataSources.update is the one endpoint where a property may be null: that // deletes the property. create_database's initial_data_source has no such // form, so the nullable lives here rather than on DATABASE_PROPERTY_SCHEMA. properties: z .record(z.string(), DATABASE_PROPERTY_SCHEMA.nullable().describe("A property definition, or null to delete the property.")) .optional() .describe("Map of property name → definition. Set a property to null to delete it from the data source, together with its values on every page."), icon: z.unknown().optional(), in_trash: z .boolean() .optional() .describe("Not accepted here — trashing is destructive and lives on delete_data_source (in_trash:false restores). Rejected here so the split is explicit."), archived: z .boolean() .optional() .describe("Deprecated alias for `in_trash`; not accepted here either. Call delete_data_source instead."), verbose: VERBOSE, }); const DeleteDataSourceParams = z.object({ data_source_id: notionId(), in_trash: z .boolean() .optional() .describe("Default true. Pass false to restore a data source from trash."), archived: z .boolean() .optional() .describe("Deprecated alias for `in_trash` (removed on the 2026-03-11 surface). Routed to `in_trash`."), verbose: VERBOSE, }); /** Names an update_data_source item sets to null, which deletes those properties. */ function deletedProperties(properties) { if (typeof properties !== "object" || properties === null) return []; return Object.entries(properties) .filter(([, definition]) => definition === null) .map(([name]) => name); } register({ name: "update_data_source", access: "write", domain: "data_sources", description: "Update a data source's schema (properties, title, icon). For database-level metadata use update_database. To trash or restore a data source use delete_data_source.", batchable: true, schema: UpdateDataSourceParams, // A null property deletes the column with its values on every page — the // same loss as trashing, minus the trash: the API has no way back. destructiveWhen: (item) => { const names = deletedProperties(item.properties); if (names.length === 0) return null; const list = names.map((n) => JSON.stringify(n)).join(", "); return `It deletes the propert${names.length === 1 ? "y" : "ies"} ${list} and ${names.length === 1 ? "its" : "their"} values on every page, which cannot be undone.`; }, example: { data_source_id: "<data-source-id>", properties: { Priority: { type: "select", select: { options: [{ name: "High", color: "red" }, { name: "Low", color: "gray" }] }, }, // `status: {}` alone gives Not started / In progress / Done. Stage: { type: "status", status: { options: [ { name: "Backlog", color: "gray", group: "To-do" }, { name: "Building", color: "blue", group: "In progress" }, { name: "Shipped", color: "green", group: "Complete" }, ], }, }, }, }, handler: tryHandler(async ({ data_source_id, title, properties, icon, archived, in_trash, verbose }) => { // Kept in the schema (rather than dropped) so a stale caller gets an error // pointing at delete_data_source instead of a silent no-op: z.object strips // unknown keys, so an absent field would make `{ in_trash: true }` succeed // with the data source untouched. if (in_trash !== undefined || archived !== undefined) { return { ok: false, error: { code: "trash_moved", message: "in_trash / archived are no longer accepted on update_data_source — trashing is a destructive operation and lives on delete_data_source.", fix: "Call delete_data_source with the same data_source_id. It trashes by default; pass in_trash:false to restore.", }, }; } const deleted = deletedProperties(properties); if (deleted.length > 0 && destructiveGroupBlocked()) { return { ok: false, error: { code: "operation_not_allowed", message: `Deleting properties (${deleted.map((n) => JSON.stringify(n)).join(", ")}) removes their values on every page, and NOTION_BLOCKED_OPERATIONS blocks destructive operations on this server.`, fix: "Leave the null properties out of the call; the rest of a schema update is allowed.", }, }; } const notion = await getClient(); const body = { data_source_id, ...(title !== undefined ? { title } : {}), ...(properties !== undefined ? { properties } : {}), ...(icon !== undefined ? { icon } : {}), }; const response = await notion.dataSources.update(asSdk(body)); // The property schema may just have changed; the write and filter paths // read it from the cache. if (response && isFullDataSource(response)) rememberDataSourceSchema(response); else forgetDataSourceSchema(data_source_id); return { ok: true, data: slimDataSource(response, verbose ?? false) }; }), }); register({ name: "delete_data_source", access: "write", domain: "data_sources", destructive: true, description: "Move a data source to trash, with every page in it. Reversible: pass in_trash:false to restore. To trash the whole database use delete_database.", batchable: true, schema: DeleteDataSourceParams, example: { data_source_id: "<data-source-id>" }, exampleBatch: { items: [{ data_source_id: "<data-source-id-1>" }, { data_source_id: "<data-source-id-2>" }], }, handler: tryHandler(async ({ data_source_id, in_trash, archived, verbose }) => { const notion = await getClient(); // `archived` was removed on the 2026-03-11 surface; route the legacy alias // into `in_trash` so we never send a field the API rejects. const response = await notion.dataSources.update(asSdk({ data_source_id, in_trash: in_trash ?? archived ?? true, })); return { ok: true, data: slimDataSource(response, verbose ?? false) }; }), });