Guide · SaaS Integration
MCP server Notion integration
Notion is a natural fit for MCP tool wrapping — agents can query project databases, create meeting notes, append action items, and update task status without leaving the conversation. This guide covers the Notion API patterns that require care: the block-based content model, Notion's 3 req/s rate limit, rich text structure, filter syntax for database queries, and OAuth2 vs integration token auth.
TL;DR
Use the @notionhq/client SDK. For internal tools, an integration token scoped to specific pages is simpler than OAuth2. Database queries use Notion's filter/sort syntax — not SQL — with nested compound conditions. Notion's rate limit is 3 requests per second per integration; add a simple request queue for burst operations. Rich text in Notion is a typed array of objects, not plain strings — flatten it to text for tool responses and build it programmatically for writes. Wire AliveMCP to monitor your MCP server — a Notion API outage will cause your MCP server's tools to return errors, and you want to know the moment the protocol probe fails.
Setup and authentication
Install the official Notion SDK and initialize it with your integration token:
npm install @notionhq/client zod
import { Client } from "@notionhq/client";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
// Internal integration token — generate at notion.so/my-integrations
const notion = new Client({
auth: process.env.NOTION_TOKEN,
timeoutMs: 10_000,
});
const server = new McpServer({ name: "notion-tools", version: "1.0.0" });
Integration tokens are scoped to pages you explicitly share with the integration in the Notion UI — the integration cannot access any page that hasn't been shared. This workspace scoping is Notion's access control model: you don't configure permissions in code, you share pages in the UI. For multi-user scenarios where different users connect their own Notion workspaces, use OAuth2 instead (covered below).
OAuth2 for user-connected workspaces
OAuth2 access tokens in Notion are long-lived (they don't expire unless revoked) and scoped to the workspace the user authorized. Store them per-user in your database:
// Exchange OAuth code for token (run this in your OAuth callback handler, outside MCP)
async function exchangeNotionCode(code: string) {
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: process.env.NOTION_REDIRECT_URI,
}),
});
return response.json(); // { access_token, workspace_id, workspace_name, bot_id, ... }
}
// Per-user Notion client in MCP tool handler
function notionForUser(accessToken: string) {
return new Client({ auth: accessToken });
}
Database query tools
Notion databases are the most common query target. The API uses a typed filter object, not SQL — each filter targets a specific property by name and type:
server.tool(
"query_notion_database",
"Query a Notion database with filters and sorting",
{
database_id: z.string().describe("Notion database ID (32-char hex or dashed UUID)"),
status_filter: z.string().optional().describe("Filter by Status property value (e.g. 'In Progress')"),
assignee_name: z.string().optional().describe("Filter by Person property — exact name match"),
due_before: z.string().datetime().optional().describe("ISO 8601 date — return items due before this date"),
sort_property: z.string().optional().describe("Property name to sort by"),
sort_direction: z.enum(["ascending", "descending"]).default("descending"),
page_size: z.number().int().min(1).max(100).default(20),
},
async ({ database_id, status_filter, assignee_name, due_before, sort_property, sort_direction, page_size }) => {
// Build compound filter from provided arguments
const filters: object[] = [];
if (status_filter) {
filters.push({ property: "Status", status: { equals: status_filter } });
}
if (assignee_name) {
filters.push({ property: "Assignee", people: { contains: assignee_name } });
}
if (due_before) {
filters.push({ property: "Due Date", date: { before: due_before } });
}
const filter = filters.length === 0
? undefined
: filters.length === 1
? filters[0]
: { and: filters };
try {
const response = await notion.databases.query({
database_id,
filter: filter as Parameters[0]["filter"],
sorts: sort_property ? [{ property: sort_property, direction: sort_direction }] : [],
page_size,
});
const results = response.results.map((page) => {
if (page.object !== "page") return null;
const props = page.properties;
return {
id: page.id,
url: page.url,
// Extract plain text from the title property
title: extractPlainText(props["Name"] || props["Title"] || props[Object.keys(props)[0]]),
status: extractStatus(props["Status"]),
created_time: page.created_time,
last_edited_time: page.last_edited_time,
};
}).filter(Boolean);
return {
content: [{
type: "text",
text: JSON.stringify({ results, has_more: response.has_more, count: results.length }),
}],
};
} catch (err) {
return notionErrorToToolResult(err);
}
}
);
// Helper: extract plain text from any Notion property
function extractPlainText(prop: object | undefined): string {
if (!prop || typeof prop !== "object") return "";
const p = prop as Record;
if ("title" in p && Array.isArray(p.title)) {
return (p.title as Array<{ plain_text: string }>).map(t => t.plain_text).join("");
}
if ("rich_text" in p && Array.isArray(p.rich_text)) {
return (p.rich_text as Array<{ plain_text: string }>).map(t => t.plain_text).join("");
}
return "";
}
function extractStatus(prop: object | undefined): string | null {
if (!prop || typeof prop !== "object") return null;
const p = prop as Record;
if ("status" in p && p.status && typeof p.status === "object") {
return (p.status as { name: string }).name;
}
return null;
}
Property names in the filter must match exactly what appears in the database header row — Notion's API is case-sensitive here. If the LLM doesn't know the exact property names, add a get_database_schema tool that calls notion.databases.retrieve() and returns the property names and types.
Page creation tools
Creating a page in Notion requires three things: a parent (database or page), a properties object matching the parent database's schema, and optional content blocks:
server.tool(
"create_notion_page",
"Create a new page in a Notion database",
{
database_id: z.string().describe("Parent database ID"),
title: z.string().max(2000).describe("Page title"),
status: z.string().optional().describe("Status property value (must match an existing option)"),
due_date: z.string().datetime().optional().describe("Due date in ISO 8601 format"),
content_markdown: z.string().max(10000).optional()
.describe("Page body as plain text (converted to paragraph blocks)"),
},
async ({ database_id, title, status, due_date, content_markdown }) => {
const properties: Record = {
// Title is always "title" type in Notion databases
Name: {
title: [{ text: { content: title } }],
},
};
if (status) {
properties["Status"] = { status: { name: status } };
}
if (due_date) {
properties["Due Date"] = { date: { start: due_date } };
}
// Convert plain text to paragraph blocks (one per newline-separated paragraph)
const children = content_markdown
? content_markdown
.split("\n\n")
.filter(para => para.trim())
.map(para => ({
object: "block" as const,
type: "paragraph" as const,
paragraph: {
rich_text: [{ type: "text" as const, text: { content: para.trim().slice(0, 2000) } }],
},
}))
: [];
try {
const page = await notion.pages.create({
parent: { database_id },
properties,
children: children.slice(0, 100), // Notion API max 100 blocks per request
});
return {
content: [{
type: "text",
text: JSON.stringify({ id: page.id, url: page.url, created_time: page.created_time }),
}],
};
} catch (err) {
return notionErrorToToolResult(err);
}
}
);
Block append tools
To add content to an existing page, append blocks. This is useful for meeting notes, appending action items, or adding comments to a task page:
server.tool(
"append_to_notion_page",
"Append content blocks to an existing Notion page",
{
page_id: z.string().describe("Notion page ID"),
content: z.string().max(10000).describe("Text to append as paragraph blocks"),
add_divider: z.boolean().default(false).describe("Add a horizontal divider before the content"),
},
async ({ page_id, content, add_divider }) => {
const paragraphs = content
.split("\n\n")
.filter(p => p.trim())
.map(para => ({
object: "block" as const,
type: "paragraph" as const,
paragraph: {
rich_text: [{ type: "text" as const, text: { content: para.trim().slice(0, 2000) } }],
},
}));
const children = [
...(add_divider ? [{ object: "block" as const, type: "divider" as const, divider: {} }] : []),
...paragraphs,
].slice(0, 100);
try {
await notion.blocks.children.append({ block_id: page_id, children });
return {
content: [{ type: "text", text: `Appended ${paragraphs.length} block(s) to page ${page_id}` }],
};
} catch (err) {
return notionErrorToToolResult(err);
}
}
);
Notion supports richer block types beyond paragraphs: heading_1/2/3, bulleted_list_item, numbered_list_item, to_do, code, and more. For agent-generated content, paragraph blocks are usually sufficient — the agent can structure content with line breaks, and Notion renders them legibly.
Rich text structure
Notion's rich text is an array of annotated text segments — not a plain string. When reading properties, always extract plain_text for tool responses. When writing, construct the array explicitly:
// Reading: extract plain text from rich_text array
function richTextToPlain(richText: Array<{ plain_text: string }>): string {
return richText.map(segment => segment.plain_text).join("");
}
// Writing: build rich_text array from plain string
function plainToRichText(text: string) {
return [{ type: "text" as const, text: { content: text.slice(0, 2000) } }];
}
// Writing: rich text with annotations (bold, italic, code, link)
function richTextWithAnnotations(text: string, annotations: {
bold?: boolean;
italic?: boolean;
code?: boolean;
href?: string;
}) {
return [{
type: "text" as const,
text: {
content: text,
link: annotations.href ? { url: annotations.href } : null,
},
annotations: {
bold: annotations.bold ?? false,
italic: annotations.italic ?? false,
strikethrough: false,
underline: false,
code: annotations.code ?? false,
color: "default" as const,
},
}];
}
A single rich_text segment can contain at most 2,000 characters. For longer content, split into multiple segments — the Notion SDK accepts an array and renders them concatenated.
Rate limit handling
The Notion API enforces 3 requests per second per integration token. For sequential tool calls this is rarely a problem, but for batch operations (creating 20 pages, querying multiple databases) you'll hit the limit quickly. A simple rate-limiting queue prevents 429 errors:
class NotionRateLimiter {
private queue: Array<() => Promise> = [];
private running = false;
async execute(fn: () => Promise): Promise {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try { resolve(await fn()); } catch (err) { reject(err); }
});
if (!this.running) this.drain();
});
}
private async drain() {
this.running = true;
while (this.queue.length > 0) {
const task = this.queue.shift()!;
await task();
if (this.queue.length > 0) {
await new Promise(r => setTimeout(r, 340)); // ~3 req/s with buffer
}
}
this.running = false;
}
}
const limiter = new NotionRateLimiter();
// Wrap all Notion calls with the limiter
const notionQuery = (params: Parameters[0]) =>
limiter.execute(() => notion.databases.query(params));
For one-off tool calls from an LLM, the Notion SDK's built-in retry on 429 (with the Retry-After header) is usually sufficient without a custom queue. The queue pattern is most useful when writing batch operations — creating a list of pages from a structured data input, for example.
Error mapping
import { APIResponseError, UnknownHTTPResponseError } from "@notionhq/client";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
function notionErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof APIResponseError) {
// Map Notion error codes to actionable messages
switch (err.code) {
case "object_not_found":
return { isError: true, content: [{ type: "text", text: `Notion object not found — verify the ID and that the integration has access to this page/database` }] };
case "unauthorized":
return { isError: true, content: [{ type: "text", text: `Notion integration token is invalid or has been revoked` }] };
case "restricted_resource":
return { isError: true, content: [{ type: "text", text: `Integration doesn't have access to this resource — share the page with the integration in Notion` }] };
case "validation_error":
return { isError: true, content: [{ type: "text", text: `Notion validation error: ${err.message}` }] };
case "rate_limited":
return { isError: true, content: [{ type: "text", text: `Notion rate limit hit (3 req/s) — retry after a moment` }] };
default:
return { isError: true, content: [{ type: "text", text: `Notion API error (${err.code}): ${err.message}` }] };
}
}
throw err;
}
The most common error agents encounter is object_not_found — this fires when the page/database ID is wrong, or when the integration hasn't been given access to the page. The error message should guide the agent to check the ID and sharing settings.
Useful read tools
server.tool(
"get_notion_page",
"Retrieve a Notion page's properties and content preview",
{ page_id: z.string() },
async ({ page_id }) => {
try {
const [page, blocks] = await Promise.all([
notion.pages.retrieve({ page_id }),
notion.blocks.children.list({ block_id: page_id, page_size: 10 }),
]);
const title = Object.values((page as { properties: Record }).properties)
.find((p): p is { type: "title"; title: Array<{ plain_text: string }> } =>
typeof p === "object" && p !== null && "type" in p && (p as { type: string }).type === "title"
);
const preview = blocks.results
.filter((b): b is { type: "paragraph"; paragraph: { rich_text: Array<{ plain_text: string }> } } =>
"type" in b && (b as { type: string }).type === "paragraph"
)
.map(b => b.paragraph.rich_text.map(t => t.plain_text).join(""))
.join("\n")
.slice(0, 500);
return {
content: [{
type: "text",
text: JSON.stringify({
id: page.id,
url: (page as { url: string }).url,
title: title?.title.map(t => t.plain_text).join("") ?? "Untitled",
last_edited_time: (page as { last_edited_time: string }).last_edited_time,
content_preview: preview,
}),
}],
};
} catch (err) {
return notionErrorToToolResult(err);
}
}
);
Frequently asked questions
How do I search across all pages an integration has access to?
Use notion.search() with a query string and optional filter to restrict to pages or databases. The search endpoint returns up to 100 results and supports cursor-based pagination via start_cursor. One limitation: Notion's search index lags behind edits by up to a few minutes, so very recently created or updated pages may not appear immediately. For real-time lookups by a known ID, use notion.pages.retrieve() directly instead of search.
Can I update an existing page's properties?
Yes — use notion.pages.update({ page_id, properties }) with only the properties you want to change. You don't need to pass all properties, only the ones to update. The same typed property structure applies: Status uses { status: { name: "Done" } }, dates use { date: { start: "2026-07-03" } }, rich text uses { rich_text: [{ text: { content: "new text" } }] }. To archive (soft-delete) a page, pass archived: true.
How do I handle Notion's cursor-based pagination?
Most list endpoints return a next_cursor field when has_more: true. Pass start_cursor: next_cursor in the next request to get the next page. For MCP tools, the practical approach is to set page_size to a reasonable limit (20–50) and surface the has_more flag in the tool response, letting the agent decide whether to fetch more. Avoid auto-paginating through all results in a single tool call — this can exhaust rate limits and produce responses too large for LLM context.
What's the right way to handle Notion database IDs in tool arguments?
Notion IDs appear in two formats: a dashed UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) and a plain 32-char hex string without dashes. The API accepts both formats, so you don't need to normalize. Users most commonly copy IDs from Notion URLs, where they appear as the path segment after the workspace name — for example, the ID in https://notion.so/workspace/PageTitle-1234abcd5678ef... is the hex string at the end. Strip any non-hex characters before passing to the API.
Can I create linked databases or database views via the API?
No — the Notion API only supports creating new full databases (as children of a page) or querying existing ones. Linked databases (views of an existing database placed on another page) and filtered views cannot be created or configured via the API as of mid-2026. If your agent needs to present a filtered view of a database, use the query endpoint with the appropriate filter and return the results as tool output rather than trying to create a view.
Further reading
- MCP server authentication — API keys, OAuth2, and session tokens
- MCP server rate limiting — per-user limits, token bucket, and backpressure
- MCP server pagination — cursor patterns for large result sets
- MCP server GitHub API integration
- MCP server Jira integration — JQL queries and issue management tools
- AliveMCP — production protocol monitoring for MCP servers