Notion API MCP Server
by pbohannon
Verified
"""
Notion Blocks API interactions.
"""
from typing import Any, Dict, List, Optional, Union
import httpx
import structlog
from datetime import datetime
from urllib.parse import urlparse, urlunparse
logger = structlog.get_logger()
class BlocksAPI:
"""
Handles interactions with Notion's Blocks API endpoints.
Supports rich text content, formatting, and block operations.
"""
def __init__(self, client: httpx.AsyncClient):
"""
Initialize BlocksAPI with an HTTP client.
Args:
client: Configured httpx AsyncClient for Notion API requests
"""
self._client = client
self._log = logger.bind(module="blocks_api")
async def get_block(self, block_id: str) -> Dict[str, Any]:
"""
Retrieve a block by ID.
Args:
block_id: ID of the block to retrieve
Returns:
Block object from Notion API
Raises:
httpx.HTTPError: On API request failure
"""
try:
response = await self._client.get(f"blocks/{block_id}")
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
self._log.error(
"get_block_error",
block_id=block_id,
error=str(e)
)
raise
async def append_children(
self,
block_id: str,
blocks: List[Dict[str, Any]],
after: Optional[str] = None,
batch_size: int = 100
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
"""
Append blocks to a page or existing block with positioning and batch support.
Args:
block_id: ID of parent block or page
blocks: List of block objects to append
after: Optional block ID to append after (for positioning)
batch_size: Maximum blocks per request (Notion limit is 100)
Returns:
Single response or list of responses for batched requests
Raises:
httpx.HTTPError: On API request failure
ValueError: If batch_size > 100
"""
if batch_size > 100:
raise ValueError("batch_size cannot exceed Notion's limit of 100 blocks per request")
try:
# Handle large arrays by chunking
if len(blocks) > batch_size:
results = []
for i in range(0, len(blocks), batch_size):
batch = blocks[i:i + batch_size]
# For batches after first, use last block of previous batch as 'after'
batch_after = after if i == 0 else blocks[i - 1].get("id")
response = await self._client.patch(
f"blocks/{block_id}/children",
json={
"children": batch,
**({"after": batch_after} if batch_after else {})
}
)
response.raise_for_status()
result = response.json()
results.append(result)
# Update after pointer for next batch if needed
if result["results"]:
after = result["results"][-1]["id"]
return results
# Single batch request
response = await self._client.patch(
f"blocks/{block_id}/children",
json={
"children": blocks,
**({"after": after} if after else {})
}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
self._log.error(
"append_children_error",
block_id=block_id,
error=str(e),
batch_size=batch_size,
total_blocks=len(blocks)
)
raise
def _normalize_url(self, url: str) -> str:
"""
Normalize URL to ensure consistent format.
Removes trailing slashes and normalizes scheme.
Args:
url: URL to normalize
Returns:
Normalized URL string
"""
parsed = urlparse(url)
# Normalize path by removing trailing slash
path = parsed.path.rstrip('/')
# Reconstruct URL with normalized components
return urlunparse((
parsed.scheme,
parsed.netloc,
path,
parsed.params,
parsed.query,
parsed.fragment
))
def create_block(
self,
block_type: str,
content: Optional[str] = None,
annotations: Optional[Dict[str, bool]] = None,
link: Optional[str] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""
Create a block of any type with appropriate structure.
Args:
block_type: Type of block (paragraph, heading_1, divider, etc.)
content: Optional text content for rich text blocks
annotations: Optional text formatting for rich text blocks
link: Optional URL for linked text
**kwargs: Additional properties specific to block type
Returns:
Block object for Notion API
"""
# Special case blocks that don't use rich_text
if block_type == "divider":
return {"type": "divider", "divider": {}}
if block_type == "column_list":
return {"type": "column_list", "column_list": {}}
if block_type == "column":
return {"type": "column", "column": {}}
# For rich text blocks
if content is not None:
text = {"content": content}
if link:
text["link"] = {"url": self._normalize_url(link)}
rich_text = {
"type": "text",
"text": text
}
if annotations:
rich_text["annotations"] = annotations
block_content = {"rich_text": [rich_text]}
else:
block_content = {}
# Add any additional properties
block_content.update(kwargs)
return {
"type": block_type,
block_type: block_content
}
def create_todo_block(
self,
content: str,
checked: bool = False,
annotations: Optional[Dict[str, bool]] = None,
is_subtask: bool = False
) -> Dict[str, Any]:
"""
Create a todo block.
Args:
content: Todo item text
checked: Whether the todo is completed
annotations: Text formatting options
is_subtask: Whether this is a subtask of another todo
Returns:
Todo block object for Notion API
"""
text = {
"type": "text",
"text": {"content": content}
}
if annotations:
text["annotations"] = annotations
return self.create_block(
"to_do",
content=content,
annotations=annotations,
checked=checked,
is_subtask=is_subtask
)
def create_bulleted_list_block(
self,
content: str,
annotations: Optional[Dict[str, bool]] = None
) -> Dict[str, Any]:
"""
Create a bulleted list item block.
Args:
content: List item text
annotations: Text formatting options
Returns:
Bulleted list block object for Notion API
"""
return self.create_block(
"bulleted_list_item",
content=content,
annotations=annotations
)
def create_rich_text_block(
self,
content: str,
annotations: Optional[Dict[str, bool]] = None,
link: Optional[str] = None,
block_type: str = "paragraph"
) -> Dict[str, Any]:
"""
Create a rich text block (paragraph, heading, etc.).
Args:
content: Text content
annotations: Text formatting options
link: Optional URL for linked text
block_type: Type of text block (paragraph, heading_1, etc.)
Returns:
Rich text block object for Notion API
"""
return self.create_block(
block_type,
content=content,
annotations=annotations,
link=link
)
def create_template_block(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
"""
DEPRECATED: Template blocks are no longer supported by Notion as of March 27, 2023.
Alternative approaches:
1. Use database templates instead
2. Create regular pages with your desired structure
3. Use the duplicate page functionality
4. Implement templating logic in your application
Raises:
NotImplementedError: Always raises this error to prevent usage
"""
raise NotImplementedError(
"Template blocks were deprecated by Notion on March 27, 2023. "
"Please use alternative approaches such as database templates, "
"regular pages with predefined structure, or implement templating "
"logic in your application."
)
async def get_block_children(
self,
block_id: str,
page_size: int = 100,
start_cursor: Optional[str] = None
) -> Dict[str, Any]:
"""
Get children blocks of a block.
Args:
block_id: Parent block ID
page_size: Number of blocks to return
start_cursor: Pagination cursor
Returns:
List of child blocks
Raises:
httpx.HTTPError: On API request failure
"""
try:
params = {"page_size": page_size}
if start_cursor:
params["start_cursor"] = start_cursor
response = await self._client.get(
f"blocks/{block_id}/children",
params=params
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
self._log.error(
"get_block_children_error",
block_id=block_id,
error=str(e)
)
raise
async def update_block(
self,
block_id: str,
properties: Dict[str, Any]
) -> Dict[str, Any]:
"""
Update a block's properties.
Args:
block_id: Block to update
properties: New properties
Returns:
Updated block object
Raises:
httpx.HTTPError: On API request failure
"""
try:
response = await self._client.patch(
f"blocks/{block_id}",
json=properties
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
self._log.error(
"update_block_error",
block_id=block_id,
error=str(e)
)
raise
async def delete_block(self, block_id: str) -> Dict[str, Any]:
"""
Delete a block.
Args:
block_id: Block to delete
Returns:
Deleted block object
Raises:
httpx.HTTPError: On API request failure
"""
try:
response = await self._client.delete(f"blocks/{block_id}")
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
self._log.error(
"delete_block_error",
block_id=block_id,
error=str(e)
)
raise
async def create_subtask(
self,
parent_id: str,
content: str,
checked: bool = False,
annotations: Optional[Dict[str, bool]] = None
) -> Dict[str, Any]:
"""
Create a subtask under a parent todo.
Args:
parent_id: ID of parent todo block
content: Subtask text content
checked: Whether subtask is completed
annotations: Text formatting options
Returns:
Created subtask block
Raises:
httpx.HTTPError: On API request failure
"""
try:
# Create subtask block
subtask = self.create_todo_block(
content,
checked,
annotations,
is_subtask=True
)
# Append as child of parent
response = await self.append_children(parent_id, [subtask])
self._log.info(
"created_subtask",
parent_id=parent_id,
content=content
)
return response
except httpx.HTTPError as e:
self._log.error(
"create_subtask_error",
parent_id=parent_id,
content=content,
error=str(e)
)
raise
async def get_subtasks(
self,
parent_id: str,
page_size: int = 100
) -> List[Dict[str, Any]]:
"""
Get all subtasks of a todo item.
Args:
parent_id: ID of parent todo block
page_size: Number of subtasks to return
Returns:
List of subtask blocks
Raises:
httpx.HTTPError: On API request failure
"""
try:
response = await self.get_block_children(
parent_id,
page_size=page_size
)
# Filter for todo blocks marked as subtasks
subtasks = [
block for block in response["results"]
if block["type"] == "to_do" and
block.get("to_do", {}).get("is_subtask", False)
]
self._log.info(
"got_subtasks",
parent_id=parent_id,
count=len(subtasks)
)
return subtasks
except httpx.HTTPError as e:
self._log.error(
"get_subtasks_error",
parent_id=parent_id,
error=str(e)
)
raise
async def update_subtask_status(
self,
subtask_id: str,
checked: bool,
update_parent: bool = True
) -> Dict[str, Any]:
"""
Update subtask completion status.
Args:
subtask_id: ID of subtask to update
checked: New completion status
update_parent: Whether to update parent status
Returns:
Updated subtask block
Raises:
httpx.HTTPError: On API request failure
"""
try:
# Update subtask status
await self.update_block(
subtask_id,
{"to_do": {"checked": checked}}
)
if update_parent:
# Get parent block
subtask = await self.get_block(subtask_id)
if parent_id := subtask.get("parent", {}).get("block_id"):
# Get all sibling subtasks
siblings = await self.get_subtasks(parent_id)
# Update parent if all subtasks complete/incomplete
all_checked = all(s["to_do"]["checked"] for s in siblings)
response = await self.update_block(
parent_id,
{"to_do": {"checked": all_checked}}
)
return response
self._log.info(
"updated_subtask_status",
subtask_id=subtask_id,
checked=checked
)
return await self.get_block(subtask_id)
except httpx.HTTPError as e:
self._log.error(
"update_subtask_status_error",
subtask_id=subtask_id,
checked=checked,
error=str(e)
)
raise