ldraney-notion-mcp 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/PKG-INFO +1 -1
  2. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/pyproject.toml +1 -1
  3. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/PKG-INFO +1 -1
  4. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/tools/blocks.py +21 -16
  5. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/tools/comments.py +13 -12
  6. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/tools/databases.py +49 -51
  7. ldraney_notion_mcp-0.1.5/src/notion_mcp/tools/pages.py +130 -0
  8. ldraney_notion_mcp-0.1.5/src/notion_mcp/tools/search.py +42 -0
  9. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/tools/users.py +5 -2
  10. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/tests/test_tools.py +219 -0
  11. ldraney_notion_mcp-0.1.3/src/notion_mcp/tools/pages.py +0 -127
  12. ldraney_notion_mcp-0.1.3/src/notion_mcp/tools/search.py +0 -41
  13. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/README.md +0 -0
  14. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/setup.cfg +0 -0
  15. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/SOURCES.txt +0 -0
  16. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/dependency_links.txt +0 -0
  17. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/entry_points.txt +0 -0
  18. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/requires.txt +0 -0
  19. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/ldraney_notion_mcp.egg-info/top_level.txt +0 -0
  20. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/__init__.py +0 -0
  21. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/__main__.py +0 -0
  22. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/server.py +0 -0
  23. {ldraney_notion_mcp-0.1.3 → ldraney_notion_mcp-0.1.5}/src/notion_mcp/tools/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ldraney-notion-mcp
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: MCP server wrapping the Notion Python SDK (v2025-09-03)
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ldraney-notion-mcp"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "MCP server wrapping the Notion Python SDK (v2025-09-03)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ldraney-notion-mcp
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: MCP server wrapping the Notion Python SDK (v2025-09-03)
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -3,13 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import Any
6
+ from typing import Annotated, Any
7
+
8
+ from pydantic import Field
7
9
 
8
10
  from ..server import mcp, get_client, _parse_json, _error_response
9
11
 
10
12
 
11
13
  @mcp.tool()
12
- def get_block(block_id: str) -> str:
14
+ def get_block(
15
+ block_id: Annotated[str, Field(description="The UUID of the block to retrieve")],
16
+ ) -> str:
13
17
  """Retrieve a Notion block by its ID.
14
18
 
15
19
  Args:
@@ -24,9 +28,9 @@ def get_block(block_id: str) -> str:
24
28
 
25
29
  @mcp.tool()
26
30
  def get_block_children(
27
- block_id: str,
28
- start_cursor: str | None = None,
29
- page_size: int | None = None,
31
+ block_id: Annotated[str, Field(description="The UUID of the parent block")],
32
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
33
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
30
34
  ) -> str:
31
35
  """List child blocks of a Notion block.
32
36
 
@@ -47,14 +51,15 @@ def get_block_children(
47
51
 
48
52
 
49
53
  @mcp.tool()
50
- def append_block_children(block_id: str, children: str) -> str:
54
+ def append_block_children(
55
+ block_id: Annotated[str, Field(description="The UUID of the parent block to append children to")],
56
+ children: Annotated[str | list, Field(description="JSON string or array for a list of block objects to append")],
57
+ ) -> str:
51
58
  """Append child blocks to a Notion block.
52
59
 
53
- IMPORTANT: The children parameter must be passed as a JSON-encoded string, NOT as an object.
54
-
55
60
  Args:
56
61
  block_id: The UUID of the parent block to append children to.
57
- children: JSON string for a list of block objects to append.
62
+ children: JSON string or array for a list of block objects to append.
58
63
  """
59
64
  try:
60
65
  result = get_client().append_block_children(
@@ -68,17 +73,15 @@ def append_block_children(block_id: str, children: str) -> str:
68
73
 
69
74
  @mcp.tool()
70
75
  def update_block(
71
- block_id: str,
72
- content: str | None = None,
76
+ block_id: Annotated[str, Field(description="The UUID of the block to update")],
77
+ content: Annotated[str | dict | None, Field(description="JSON string or object of block properties to update. The keys depend on the block type, e.g. {\"paragraph\": {\"rich_text\": [...]}}")] = None,
73
78
  ) -> str:
74
79
  """Update a Notion block.
75
80
 
76
- IMPORTANT: The content parameter must be passed as a JSON-encoded string, NOT as an object.
77
-
78
81
  Args:
79
82
  block_id: The UUID of the block to update.
80
- content: JSON string of block properties to update. The keys depend
81
- on the block type (e.g. '{"paragraph": {"rich_text": [...]}}').
83
+ content: JSON string or object of block properties to update. The keys depend
84
+ on the block type (e.g. {"paragraph": {"rich_text": [...]}}).
82
85
  """
83
86
  try:
84
87
  kwargs: dict[str, Any] = {}
@@ -91,7 +94,9 @@ def update_block(
91
94
 
92
95
 
93
96
  @mcp.tool()
94
- def delete_block(block_id: str) -> str:
97
+ def delete_block(
98
+ block_id: Annotated[str, Field(description="The UUID of the block to delete")],
99
+ ) -> str:
95
100
  """Delete (archive) a Notion block.
96
101
 
97
102
  Args:
@@ -3,25 +3,26 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Annotated
7
+
8
+ from pydantic import Field
6
9
 
7
10
  from ..server import mcp, get_client, _parse_json, _error_response
8
11
 
9
12
 
10
13
  @mcp.tool()
11
14
  def create_comment(
12
- parent: str,
13
- rich_text: str,
14
- discussion_id: str | None = None,
15
+ parent: Annotated[str | dict, Field(description="JSON string or object for the parent, e.g. {\"page_id\": \"...\"}")],
16
+ rich_text: Annotated[str | list, Field(description="JSON string or array for the rich-text content, e.g. [{\"type\": \"text\", \"text\": {\"content\": \"Hello!\"}}]")],
17
+ discussion_id: Annotated[str | None, Field(description="UUID of an existing discussion thread to reply to. Omit to start a new top-level comment.")] = None,
15
18
  ) -> str:
16
19
  """Create a comment on a Notion page or block.
17
20
 
18
- IMPORTANT: parent and rich_text must be passed as JSON-encoded strings, NOT as objects.
19
-
20
21
  Args:
21
- parent: JSON string for the parent object,
22
- e.g. '{"page_id": "..."}'.
23
- rich_text: JSON string for the rich-text content array,
24
- e.g. '[{"type": "text", "text": {"content": "Hello!"}}]'.
22
+ parent: JSON string or object for the parent,
23
+ e.g. {"page_id": "..."}.
24
+ rich_text: JSON string or array for the rich-text content,
25
+ e.g. [{"type": "text", "text": {"content": "Hello!"}}].
25
26
  discussion_id: Optional UUID of an existing discussion thread
26
27
  to reply to. Omit to start a new top-level comment.
27
28
  """
@@ -41,9 +42,9 @@ def create_comment(
41
42
 
42
43
  @mcp.tool()
43
44
  def get_comments(
44
- block_id: str,
45
- start_cursor: str | None = None,
46
- page_size: int | None = None,
45
+ block_id: Annotated[str, Field(description="The UUID of the block or page to list comments for")],
46
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
47
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
47
48
  ) -> str:
48
49
  """List comments on a Notion block or page.
49
50
 
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import Any
6
+ from typing import Annotated, Any
7
+
8
+ from pydantic import Field
7
9
 
8
10
  from ..server import mcp, get_client, _parse_json, _error_response
9
11
 
@@ -15,22 +17,20 @@ from ..server import mcp, get_client, _parse_json, _error_response
15
17
 
16
18
  @mcp.tool()
17
19
  def create_database(
18
- parent: str,
19
- title: str,
20
- initial_data_source: str | None = None,
20
+ parent: Annotated[str | dict, Field(description="JSON string or object for the parent, e.g. {\"type\": \"page_id\", \"page_id\": \"...\"}")],
21
+ title: Annotated[str | list, Field(description="JSON string or array for the title rich-text array, e.g. [{\"type\": \"text\", \"text\": {\"content\": \"My DB\"}}]")],
22
+ initial_data_source: Annotated[str | dict | None, Field(description="JSON string or object for the initial data source configuration including properties")] = None,
21
23
  ) -> str:
22
24
  """Create a new Notion database.
23
25
 
24
26
  In Notion API v2025-09-03, database properties live on data sources.
25
27
  Pass properties inside initial_data_source.properties.
26
28
 
27
- IMPORTANT: All structured parameters must be passed as JSON-encoded strings, NOT as objects.
28
-
29
29
  Args:
30
- parent: JSON string for the parent object, e.g. '{"type": "page_id", "page_id": "..."}'.
31
- title: JSON string for the title rich-text array,
32
- e.g. '[{"type": "text", "text": {"content": "My DB"}}]'.
33
- initial_data_source: Optional JSON string for the initial data source
30
+ parent: JSON string or object for the parent, e.g. {"type": "page_id", "page_id": "..."}.
31
+ title: JSON string or array for the title rich-text array,
32
+ e.g. [{"type": "text", "text": {"content": "My DB"}}].
33
+ initial_data_source: Optional JSON string or object for the initial data source
34
34
  configuration including properties.
35
35
  """
36
36
  try:
@@ -45,7 +45,9 @@ def create_database(
45
45
 
46
46
 
47
47
  @mcp.tool()
48
- def get_database(database_id: str) -> str:
48
+ def get_database(
49
+ database_id: Annotated[str, Field(description="The UUID of the database to retrieve")],
50
+ ) -> str:
49
51
  """Retrieve a Notion database by its ID.
50
52
 
51
53
  Note: In v2025-09-03 the response contains data_sources but NOT
@@ -63,22 +65,20 @@ def get_database(database_id: str) -> str:
63
65
 
64
66
  @mcp.tool()
65
67
  def update_database(
66
- database_id: str,
67
- title: str | None = None,
68
- description: str | None = None,
69
- icon: str | None = None,
70
- cover: str | None = None,
68
+ database_id: Annotated[str, Field(description="The UUID of the database to update")],
69
+ title: Annotated[str | list | None, Field(description="JSON string or array for the new title rich-text array")] = None,
70
+ description: Annotated[str | list | None, Field(description="JSON string or array for the description rich-text array")] = None,
71
+ icon: Annotated[str | dict | None, Field(description="JSON string or object for the database icon, e.g. {\"type\": \"emoji\", \"emoji\": \"...\"}")] = None,
72
+ cover: Annotated[str | dict | None, Field(description="JSON string or object for the database cover, e.g. {\"type\": \"external\", \"external\": {\"url\": \"https://...\"}}")] = None,
71
73
  ) -> str:
72
74
  """Update a Notion database.
73
75
 
74
- IMPORTANT: All structured parameters must be passed as JSON-encoded strings, NOT as objects.
75
-
76
76
  Args:
77
77
  database_id: The UUID of the database to update.
78
- title: Optional JSON string for the new title rich-text array.
79
- description: Optional JSON string for the description rich-text array.
80
- icon: Optional JSON string for the database icon.
81
- cover: Optional JSON string for the database cover.
78
+ title: Optional JSON string or array for the new title rich-text array.
79
+ description: Optional JSON string or array for the description rich-text array.
80
+ icon: Optional JSON string or object for the database icon.
81
+ cover: Optional JSON string or object for the database cover.
82
82
  """
83
83
  try:
84
84
  kwargs: dict[str, Any] = {}
@@ -97,7 +97,9 @@ def update_database(
97
97
 
98
98
 
99
99
  @mcp.tool()
100
- def archive_database(database_id: str) -> str:
100
+ def archive_database(
101
+ database_id: Annotated[str, Field(description="The UUID of the database to archive")],
102
+ ) -> str:
101
103
  """Archive a Notion database.
102
104
 
103
105
  Args:
@@ -112,23 +114,21 @@ def archive_database(database_id: str) -> str:
112
114
 
113
115
  @mcp.tool()
114
116
  def query_database(
115
- database_id: str,
116
- filter: str | None = None,
117
- sorts: str | None = None,
118
- start_cursor: str | None = None,
119
- page_size: int | None = None,
117
+ database_id: Annotated[str, Field(description="The UUID of the database to query")],
118
+ filter: Annotated[str | dict | None, Field(description="JSON string or object for a filter, e.g. {\"property\": \"Status\", \"select\": {\"equals\": \"Done\"}}")] = None,
119
+ sorts: Annotated[str | list | None, Field(description="JSON string or array for a list of sort objects, e.g. [{\"property\": \"Created\", \"direction\": \"descending\"}]")] = None,
120
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
121
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
120
122
  ) -> str:
121
123
  """Query a Notion database for pages/rows.
122
124
 
123
125
  Automatically resolves the first data source and queries it.
124
126
  If you already know the data source ID, use query_data_source instead.
125
127
 
126
- IMPORTANT: filter and sorts must be passed as JSON-encoded strings, NOT as objects.
127
-
128
128
  Args:
129
129
  database_id: The UUID of the database to query.
130
- filter: Optional JSON string for a filter object.
131
- sorts: Optional JSON string for a list of sort objects.
130
+ filter: Optional JSON string or object for a filter.
131
+ sorts: Optional JSON string or array for a list of sort objects.
132
132
  start_cursor: Optional cursor for pagination.
133
133
  page_size: Optional number of results per page.
134
134
  """
@@ -151,7 +151,9 @@ def query_database(
151
151
 
152
152
 
153
153
  @mcp.tool()
154
- def get_data_source(data_source_id: str) -> str:
154
+ def get_data_source(
155
+ data_source_id: Annotated[str, Field(description="The UUID of the data source to retrieve")],
156
+ ) -> str:
155
157
  """Retrieve a Notion data source (includes properties).
156
158
 
157
159
  Args:
@@ -166,16 +168,14 @@ def get_data_source(data_source_id: str) -> str:
166
168
 
167
169
  @mcp.tool()
168
170
  def update_data_source(
169
- data_source_id: str,
170
- properties: str | None = None,
171
+ data_source_id: Annotated[str, Field(description="The UUID of the data source to update")],
172
+ properties: Annotated[str | dict | None, Field(description="JSON string or object for properties to update")] = None,
171
173
  ) -> str:
172
174
  """Update a Notion data source.
173
175
 
174
- IMPORTANT: The properties parameter must be passed as a JSON-encoded string, NOT as an object.
175
-
176
176
  Args:
177
177
  data_source_id: The UUID of the data source to update.
178
- properties: Optional JSON string for properties to update.
178
+ properties: Optional JSON string or object for properties to update.
179
179
  """
180
180
  try:
181
181
  kwargs: dict[str, Any] = {}
@@ -189,20 +189,18 @@ def update_data_source(
189
189
 
190
190
  @mcp.tool()
191
191
  def query_data_source(
192
- data_source_id: str,
193
- filter: str | None = None,
194
- sorts: str | None = None,
195
- start_cursor: str | None = None,
196
- page_size: int | None = None,
192
+ data_source_id: Annotated[str, Field(description="The UUID of the data source to query")],
193
+ filter: Annotated[str | dict | None, Field(description="JSON string or object for a filter, e.g. {\"property\": \"Status\", \"select\": {\"equals\": \"Done\"}}")] = None,
194
+ sorts: Annotated[str | list | None, Field(description="JSON string or array for a list of sort objects, e.g. [{\"property\": \"Created\", \"direction\": \"descending\"}]")] = None,
195
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
196
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
197
197
  ) -> str:
198
198
  """Query rows in a Notion data source.
199
199
 
200
- IMPORTANT: filter and sorts must be passed as JSON-encoded strings, NOT as objects.
201
-
202
200
  Args:
203
201
  data_source_id: The UUID of the data source to query.
204
- filter: Optional JSON string for a filter object.
205
- sorts: Optional JSON string for a list of sort objects.
202
+ filter: Optional JSON string or object for a filter.
203
+ sorts: Optional JSON string or array for a list of sort objects.
206
204
  start_cursor: Optional cursor for pagination.
207
205
  page_size: Optional number of results per page.
208
206
  """
@@ -221,10 +219,10 @@ def query_data_source(
221
219
 
222
220
  @mcp.tool()
223
221
  def list_data_source_templates(
224
- data_source_id: str,
225
- name: str | None = None,
226
- start_cursor: str | None = None,
227
- page_size: int | None = None,
222
+ data_source_id: Annotated[str, Field(description="The UUID of the data source")],
223
+ name: Annotated[str | None, Field(description="Name filter for templates")] = None,
224
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
225
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
228
226
  ) -> str:
229
227
  """List templates for a Notion data source.
230
228
 
@@ -0,0 +1,130 @@
1
+ """Page tools — wraps PagesMixin methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated, Any
7
+
8
+ from pydantic import Field
9
+
10
+ from ..server import mcp, get_client, _parse_json, _error_response
11
+
12
+
13
+ @mcp.tool()
14
+ def create_page(
15
+ parent: Annotated[str | dict, Field(description="JSON string or object for the parent, e.g. {\"type\": \"page_id\", \"page_id\": \"...\"}")],
16
+ properties: Annotated[str | dict, Field(description="JSON string or object for the page properties mapping")],
17
+ children: Annotated[str | list | None, Field(description="JSON string or array for a list of block children to append. Cannot be used together with template.")] = None,
18
+ template: Annotated[str | dict | None, Field(description="JSON string or object for a data-source template, e.g. {\"type\": \"none\"}, {\"type\": \"default\"}, or {\"type\": \"template_id\", \"template_id\": \"<uuid>\"}")] = None,
19
+ ) -> str:
20
+ """Create a new Notion page.
21
+
22
+ Args:
23
+ parent: JSON string or object for the parent object, e.g. {"type": "page_id", "page_id": "..."}.
24
+ properties: JSON string or object for the page properties mapping.
25
+ children: Optional JSON string or array for a list of block children to append.
26
+ Cannot be used together with template.
27
+ template: Optional JSON string or object for a data-source template, e.g.
28
+ {"type": "none"}, {"type": "default"}, or
29
+ {"type": "template_id", "template_id": "<uuid>"}.
30
+ """
31
+ try:
32
+ result = get_client().create_page(
33
+ parent=_parse_json(parent, "parent"),
34
+ properties=_parse_json(properties, "properties"),
35
+ children=_parse_json(children, "children"),
36
+ template=_parse_json(template, "template"),
37
+ )
38
+ return json.dumps(result, indent=2)
39
+ except Exception as exc:
40
+ return _error_response(exc)
41
+
42
+
43
+ @mcp.tool()
44
+ def get_page(
45
+ page_id: Annotated[str, Field(description="The UUID of the page to retrieve")],
46
+ ) -> str:
47
+ """Retrieve a Notion page by its ID.
48
+
49
+ Args:
50
+ page_id: The UUID of the page to retrieve.
51
+ """
52
+ try:
53
+ result = get_client().get_page(page_id)
54
+ return json.dumps(result, indent=2)
55
+ except Exception as exc:
56
+ return _error_response(exc)
57
+
58
+
59
+ @mcp.tool()
60
+ def update_page(
61
+ page_id: Annotated[str, Field(description="The UUID of the page to update")],
62
+ properties: Annotated[str | dict | None, Field(description="JSON string or object of properties to update")] = None,
63
+ erase_content: Annotated[bool | None, Field(description="If true, clears ALL block content from the page. WARNING: This is destructive and irreversible.")] = None,
64
+ icon: Annotated[str | dict | None, Field(description="JSON string or object for the page icon, e.g. {\"type\": \"emoji\", \"emoji\": \"...\"}")] = None,
65
+ cover: Annotated[str | dict | None, Field(description="JSON string or object for the page cover, e.g. {\"type\": \"external\", \"external\": {\"url\": \"https://...\"}}")] = None,
66
+ ) -> str:
67
+ """Update a Notion page's properties, icon, or cover.
68
+
69
+ Args:
70
+ page_id: The UUID of the page to update.
71
+ properties: Optional JSON string or object of properties to update.
72
+ erase_content: If true, clears ALL block content from the page.
73
+ WARNING: This is destructive and irreversible.
74
+ icon: Optional JSON string or object for the page icon.
75
+ cover: Optional JSON string or object for the page cover.
76
+ """
77
+ try:
78
+ kwargs: dict[str, Any] = {}
79
+ if properties is not None:
80
+ kwargs["properties"] = _parse_json(properties, "properties")
81
+ if icon is not None:
82
+ kwargs["icon"] = _parse_json(icon, "icon")
83
+ if cover is not None:
84
+ kwargs["cover"] = _parse_json(cover, "cover")
85
+ result = get_client().update_page(
86
+ page_id,
87
+ erase_content=erase_content,
88
+ **kwargs,
89
+ )
90
+ return json.dumps(result, indent=2)
91
+ except Exception as exc:
92
+ return _error_response(exc)
93
+
94
+
95
+ @mcp.tool()
96
+ def archive_page(
97
+ page_id: Annotated[str, Field(description="The UUID of the page to archive")],
98
+ ) -> str:
99
+ """Archive (soft-delete) a Notion page.
100
+
101
+ Args:
102
+ page_id: The UUID of the page to archive.
103
+ """
104
+ try:
105
+ result = get_client().archive_page(page_id)
106
+ return json.dumps(result, indent=2)
107
+ except Exception as exc:
108
+ return _error_response(exc)
109
+
110
+
111
+ @mcp.tool()
112
+ def move_page(
113
+ page_id: Annotated[str, Field(description="The UUID of the page to move")],
114
+ parent: Annotated[str | dict, Field(description="JSON string or object for the new parent, e.g. {\"type\": \"page_id\", \"page_id\": \"...\"}")],
115
+ ) -> str:
116
+ """Move a Notion page to a new parent.
117
+
118
+ Args:
119
+ page_id: The UUID of the page to move.
120
+ parent: JSON string or object for the new parent object,
121
+ e.g. {"type": "page_id", "page_id": "..."}.
122
+ """
123
+ try:
124
+ result = get_client().move_page(
125
+ page_id,
126
+ parent=_parse_json(parent, "parent"),
127
+ )
128
+ return json.dumps(result, indent=2)
129
+ except Exception as exc:
130
+ return _error_response(exc)
@@ -0,0 +1,42 @@
1
+ """Search tool — wraps SearchMixin method."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ from pydantic import Field
9
+
10
+ from ..server import mcp, get_client, _parse_json, _error_response
11
+
12
+
13
+ @mcp.tool()
14
+ def search(
15
+ query: Annotated[str | None, Field(description="Text query to search for")] = None,
16
+ filter: Annotated[str | dict | None, Field(description="JSON string or object for a filter, e.g. {\"value\": \"page\", \"property\": \"object\"}")] = None,
17
+ sort: Annotated[str | dict | None, Field(description="JSON string or object for a sort, e.g. {\"direction\": \"ascending\", \"timestamp\": \"last_edited_time\"}")] = None,
18
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
19
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
20
+ ) -> str:
21
+ """Search Notion pages and databases.
22
+
23
+ Args:
24
+ query: Optional text query to search for.
25
+ filter: Optional JSON string or object for a filter,
26
+ e.g. {"value": "page", "property": "object"}.
27
+ sort: Optional JSON string or object for a sort,
28
+ e.g. {"direction": "ascending", "timestamp": "last_edited_time"}.
29
+ start_cursor: Optional cursor for pagination.
30
+ page_size: Optional number of results per page.
31
+ """
32
+ try:
33
+ result = get_client().search(
34
+ query=query,
35
+ filter=_parse_json(filter, "filter"),
36
+ sort=_parse_json(sort, "sort"),
37
+ start_cursor=start_cursor,
38
+ page_size=page_size,
39
+ )
40
+ return json.dumps(result, indent=2)
41
+ except Exception as exc:
42
+ return _error_response(exc)
@@ -3,14 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Annotated
7
+
8
+ from pydantic import Field
6
9
 
7
10
  from ..server import mcp, get_client, _error_response
8
11
 
9
12
 
10
13
  @mcp.tool()
11
14
  def get_users(
12
- start_cursor: str | None = None,
13
- page_size: int | None = None,
15
+ start_cursor: Annotated[str | None, Field(description="Cursor for pagination")] = None,
16
+ page_size: Annotated[int | None, Field(description="Number of results per page")] = None,
14
17
  ) -> str:
15
18
  """List all users in the Notion workspace.
16
19
 
@@ -499,6 +499,225 @@ class TestErrorHandling:
499
499
  assert parsed["details"]["code"] == "rate_limited"
500
500
 
501
501
 
502
+ # ---------------------------------------------------------------------------
503
+ # Raw dict/list parameter tests (no JSON string encoding)
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ class TestRawDictParams:
508
+ """Verify that passing native dicts/lists (not JSON strings) works.
509
+
510
+ LLMs often send raw objects instead of JSON-encoded strings. The widened
511
+ type annotations (str | dict, str | list, etc.) let pydantic accept them,
512
+ and _parse_json already passes them through unchanged.
513
+ """
514
+
515
+ def test_create_page_raw_dict(self, mock_client):
516
+ from notion_mcp.tools.pages import create_page
517
+
518
+ mock_client.create_page.return_value = {"id": "page-raw"}
519
+ result = create_page(
520
+ parent={"type": "page_id", "page_id": "abc"},
521
+ properties={"title": [{"text": {"content": "Raw"}}]},
522
+ )
523
+ parsed = json.loads(result)
524
+ assert parsed["id"] == "page-raw"
525
+ mock_client.create_page.assert_called_once_with(
526
+ parent={"type": "page_id", "page_id": "abc"},
527
+ properties={"title": [{"text": {"content": "Raw"}}]},
528
+ children=None,
529
+ template=None,
530
+ )
531
+
532
+ def test_create_page_raw_children_list(self, mock_client):
533
+ from notion_mcp.tools.pages import create_page
534
+
535
+ mock_client.create_page.return_value = {"id": "page-ch"}
536
+ children = [{"object": "block", "type": "paragraph"}]
537
+ result = create_page(
538
+ parent={"type": "page_id", "page_id": "abc"},
539
+ properties={"title": []},
540
+ children=children,
541
+ )
542
+ parsed = json.loads(result)
543
+ assert parsed["id"] == "page-ch"
544
+ call_kwargs = mock_client.create_page.call_args.kwargs
545
+ assert call_kwargs["children"] == children
546
+
547
+ def test_create_page_raw_template_dict(self, mock_client):
548
+ from notion_mcp.tools.pages import create_page
549
+
550
+ mock_client.create_page.return_value = {"id": "page-tmpl"}
551
+ template = {"type": "template_id", "template_id": "tmpl-abc"}
552
+ result = create_page(
553
+ parent={"type": "page_id", "page_id": "abc"},
554
+ properties={"title": []},
555
+ template=template,
556
+ )
557
+ parsed = json.loads(result)
558
+ assert parsed["id"] == "page-tmpl"
559
+ mock_client.create_page.assert_called_once_with(
560
+ parent={"type": "page_id", "page_id": "abc"},
561
+ properties={"title": []},
562
+ children=None,
563
+ template=template,
564
+ )
565
+
566
+ def test_update_page_raw_dicts(self, mock_client):
567
+ from notion_mcp.tools.pages import update_page
568
+
569
+ mock_client.update_page.return_value = {"id": "page-1"}
570
+ result = update_page(
571
+ page_id="page-1",
572
+ properties={"title": [{"text": {"content": "New"}}]},
573
+ icon={"type": "emoji", "emoji": "X"},
574
+ cover={"type": "external", "external": {"url": "https://example.com"}},
575
+ )
576
+ parsed = json.loads(result)
577
+ assert parsed["id"] == "page-1"
578
+ mock_client.update_page.assert_called_once_with(
579
+ "page-1",
580
+ erase_content=None,
581
+ properties={"title": [{"text": {"content": "New"}}]},
582
+ icon={"type": "emoji", "emoji": "X"},
583
+ cover={"type": "external", "external": {"url": "https://example.com"}},
584
+ )
585
+
586
+ def test_move_page_raw_dict(self, mock_client):
587
+ from notion_mcp.tools.pages import move_page
588
+
589
+ mock_client.move_page.return_value = {"id": "page-1"}
590
+ parent = {"type": "page_id", "page_id": "new-parent"}
591
+ result = move_page(page_id="page-1", parent=parent)
592
+ parsed = json.loads(result)
593
+ assert parsed["id"] == "page-1"
594
+ mock_client.move_page.assert_called_once_with("page-1", parent=parent)
595
+
596
+ def test_create_database_raw(self, mock_client):
597
+ from notion_mcp.tools.databases import create_database
598
+
599
+ mock_client.create_database.return_value = {"id": "db-raw"}
600
+ result = create_database(
601
+ parent={"type": "page_id", "page_id": "abc"},
602
+ title=[{"type": "text", "text": {"content": "My DB"}}],
603
+ initial_data_source={"properties": {"Name": {"title": {}}}},
604
+ )
605
+ parsed = json.loads(result)
606
+ assert parsed["id"] == "db-raw"
607
+ mock_client.create_database.assert_called_once_with(
608
+ parent={"type": "page_id", "page_id": "abc"},
609
+ title=[{"type": "text", "text": {"content": "My DB"}}],
610
+ initial_data_source={"properties": {"Name": {"title": {}}}},
611
+ )
612
+
613
+ def test_update_database_raw(self, mock_client):
614
+ from notion_mcp.tools.databases import update_database
615
+
616
+ mock_client.update_database.return_value = {"id": "db-1"}
617
+ result = update_database(
618
+ database_id="db-1",
619
+ title=[{"type": "text", "text": {"content": "Renamed"}}],
620
+ description=[{"type": "text", "text": {"content": "Desc"}}],
621
+ icon={"type": "emoji", "emoji": "X"},
622
+ cover={"type": "external", "external": {"url": "https://x.com"}},
623
+ )
624
+ parsed = json.loads(result)
625
+ assert parsed["id"] == "db-1"
626
+
627
+ def test_query_database_raw(self, mock_client):
628
+ from notion_mcp.tools.databases import query_database
629
+
630
+ mock_client.query_database.return_value = {"results": [], "has_more": False}
631
+ filter_obj = {"property": "Status", "select": {"equals": "Done"}}
632
+ sorts_obj = [{"property": "Created", "direction": "descending"}]
633
+ result = query_database("db-1", filter=filter_obj, sorts=sorts_obj)
634
+ parsed = json.loads(result)
635
+ assert parsed["has_more"] is False
636
+ mock_client.query_database.assert_called_once_with(
637
+ "db-1",
638
+ filter=filter_obj,
639
+ sorts=sorts_obj,
640
+ start_cursor=None,
641
+ page_size=None,
642
+ )
643
+
644
+ def test_update_data_source_raw(self, mock_client):
645
+ from notion_mcp.tools.databases import update_data_source
646
+
647
+ mock_client.update_data_source.return_value = {"id": "ds-1"}
648
+ props = {"Name": {"title": {}}}
649
+ result = update_data_source("ds-1", properties=props)
650
+ parsed = json.loads(result)
651
+ assert parsed["id"] == "ds-1"
652
+
653
+ def test_query_data_source_raw(self, mock_client):
654
+ from notion_mcp.tools.databases import query_data_source
655
+
656
+ mock_client.query_data_source.return_value = {"results": []}
657
+ filter_obj = {"property": "Done", "checkbox": {"equals": True}}
658
+ sorts_obj = [{"property": "Name", "direction": "ascending"}]
659
+ result = query_data_source("ds-1", filter=filter_obj, sorts=sorts_obj)
660
+ parsed = json.loads(result)
661
+ assert "results" in parsed
662
+
663
+ def test_append_block_children_raw(self, mock_client):
664
+ from notion_mcp.tools.blocks import append_block_children
665
+
666
+ mock_client.append_block_children.return_value = {"results": [{"id": "b1"}]}
667
+ children = [{"object": "block", "type": "paragraph", "paragraph": {"rich_text": []}}]
668
+ result = append_block_children("block-1", children=children)
669
+ parsed = json.loads(result)
670
+ assert len(parsed["results"]) == 1
671
+ mock_client.append_block_children.assert_called_once_with(
672
+ "block-1", children=children,
673
+ )
674
+
675
+ def test_update_block_raw(self, mock_client):
676
+ from notion_mcp.tools.blocks import update_block
677
+
678
+ mock_client.update_block.return_value = {"id": "block-1"}
679
+ content = {"paragraph": {"rich_text": [{"text": {"content": "updated"}}]}}
680
+ result = update_block("block-1", content=content)
681
+ parsed = json.loads(result)
682
+ assert parsed["id"] == "block-1"
683
+ mock_client.update_block.assert_called_once_with(
684
+ "block-1",
685
+ paragraph={"rich_text": [{"text": {"content": "updated"}}]},
686
+ )
687
+
688
+ def test_create_comment_raw(self, mock_client):
689
+ from notion_mcp.tools.comments import create_comment
690
+
691
+ mock_client.create_comment.return_value = {"id": "comment-raw"}
692
+ result = create_comment(
693
+ parent={"page_id": "page-1"},
694
+ rich_text=[{"type": "text", "text": {"content": "Nice!"}}],
695
+ )
696
+ parsed = json.loads(result)
697
+ assert parsed["id"] == "comment-raw"
698
+ mock_client.create_comment.assert_called_once_with(
699
+ parent={"page_id": "page-1"},
700
+ rich_text=[{"type": "text", "text": {"content": "Nice!"}}],
701
+ )
702
+
703
+ def test_search_raw(self, mock_client):
704
+ from notion_mcp.tools.search import search
705
+
706
+ mock_client.search.return_value = {"results": [], "has_more": False}
707
+ filter_obj = {"value": "page", "property": "object"}
708
+ sort_obj = {"direction": "ascending", "timestamp": "last_edited_time"}
709
+ result = search(query="test", filter=filter_obj, sort=sort_obj)
710
+ parsed = json.loads(result)
711
+ assert parsed["has_more"] is False
712
+ mock_client.search.assert_called_once_with(
713
+ query="test",
714
+ filter=filter_obj,
715
+ sort=sort_obj,
716
+ start_cursor=None,
717
+ page_size=None,
718
+ )
719
+
720
+
502
721
  # ---------------------------------------------------------------------------
503
722
  # Helper tests
504
723
  # ---------------------------------------------------------------------------
@@ -1,127 +0,0 @@
1
- """Page tools — wraps PagesMixin methods."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from typing import Any
7
-
8
- from ..server import mcp, get_client, _parse_json, _error_response
9
-
10
-
11
- @mcp.tool()
12
- def create_page(
13
- parent: str,
14
- properties: str,
15
- children: str | None = None,
16
- template: str | None = None,
17
- ) -> str:
18
- """Create a new Notion page.
19
-
20
- IMPORTANT: All structured parameters must be passed as JSON-encoded strings, NOT as objects.
21
-
22
- Args:
23
- parent: JSON string for the parent object, e.g. '{"type": "page_id", "page_id": "..."}'.
24
- properties: JSON string for the page properties mapping.
25
- children: Optional JSON string for a list of block children to append.
26
- Cannot be used together with template.
27
- template: Optional JSON string for a data-source template, e.g.
28
- '{"type": "none"}', '{"type": "default"}', or
29
- '{"type": "template_id", "template_id": "<uuid>"}'.
30
- """
31
- try:
32
- result = get_client().create_page(
33
- parent=_parse_json(parent, "parent"),
34
- properties=_parse_json(properties, "properties"),
35
- children=_parse_json(children, "children"),
36
- template=_parse_json(template, "template"),
37
- )
38
- return json.dumps(result, indent=2)
39
- except Exception as exc:
40
- return _error_response(exc)
41
-
42
-
43
- @mcp.tool()
44
- def get_page(page_id: str) -> str:
45
- """Retrieve a Notion page by its ID.
46
-
47
- Args:
48
- page_id: The UUID of the page to retrieve.
49
- """
50
- try:
51
- result = get_client().get_page(page_id)
52
- return json.dumps(result, indent=2)
53
- except Exception as exc:
54
- return _error_response(exc)
55
-
56
-
57
- @mcp.tool()
58
- def update_page(
59
- page_id: str,
60
- properties: str | None = None,
61
- erase_content: bool | None = None,
62
- icon: str | None = None,
63
- cover: str | None = None,
64
- ) -> str:
65
- """Update a Notion page's properties, icon, or cover.
66
-
67
- IMPORTANT: All structured parameters must be passed as JSON-encoded strings, NOT as objects.
68
-
69
- Args:
70
- page_id: The UUID of the page to update.
71
- properties: Optional JSON string of properties to update.
72
- erase_content: If true, clears ALL block content from the page.
73
- WARNING: This is destructive and irreversible.
74
- icon: Optional JSON string for the page icon.
75
- cover: Optional JSON string for the page cover.
76
- """
77
- try:
78
- kwargs: dict[str, Any] = {}
79
- if properties is not None:
80
- kwargs["properties"] = _parse_json(properties, "properties")
81
- if icon is not None:
82
- kwargs["icon"] = _parse_json(icon, "icon")
83
- if cover is not None:
84
- kwargs["cover"] = _parse_json(cover, "cover")
85
- result = get_client().update_page(
86
- page_id,
87
- erase_content=erase_content,
88
- **kwargs,
89
- )
90
- return json.dumps(result, indent=2)
91
- except Exception as exc:
92
- return _error_response(exc)
93
-
94
-
95
- @mcp.tool()
96
- def archive_page(page_id: str) -> str:
97
- """Archive (soft-delete) a Notion page.
98
-
99
- Args:
100
- page_id: The UUID of the page to archive.
101
- """
102
- try:
103
- result = get_client().archive_page(page_id)
104
- return json.dumps(result, indent=2)
105
- except Exception as exc:
106
- return _error_response(exc)
107
-
108
-
109
- @mcp.tool()
110
- def move_page(page_id: str, parent: str) -> str:
111
- """Move a Notion page to a new parent.
112
-
113
- IMPORTANT: The parent parameter must be passed as a JSON-encoded string, NOT as an object.
114
-
115
- Args:
116
- page_id: The UUID of the page to move.
117
- parent: JSON string for the new parent object,
118
- e.g. '{"type": "page_id", "page_id": "..."}'.
119
- """
120
- try:
121
- result = get_client().move_page(
122
- page_id,
123
- parent=_parse_json(parent, "parent"),
124
- )
125
- return json.dumps(result, indent=2)
126
- except Exception as exc:
127
- return _error_response(exc)
@@ -1,41 +0,0 @@
1
- """Search tool — wraps SearchMixin method."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
-
7
- from ..server import mcp, get_client, _parse_json, _error_response
8
-
9
-
10
- @mcp.tool()
11
- def search(
12
- query: str | None = None,
13
- filter: str | None = None,
14
- sort: str | None = None,
15
- start_cursor: str | None = None,
16
- page_size: int | None = None,
17
- ) -> str:
18
- """Search Notion pages and databases.
19
-
20
- IMPORTANT: filter and sort must be passed as JSON-encoded strings, NOT as objects.
21
-
22
- Args:
23
- query: Optional text query to search for.
24
- filter: Optional JSON string for a filter object,
25
- e.g. '{"value": "page", "property": "object"}'.
26
- sort: Optional JSON string for a sort object,
27
- e.g. '{"direction": "ascending", "timestamp": "last_edited_time"}'.
28
- start_cursor: Optional cursor for pagination.
29
- page_size: Optional number of results per page.
30
- """
31
- try:
32
- result = get_client().search(
33
- query=query,
34
- filter=_parse_json(filter, "filter"),
35
- sort=_parse_json(sort, "sort"),
36
- start_cursor=start_cursor,
37
- page_size=page_size,
38
- )
39
- return json.dumps(result, indent=2)
40
- except Exception as exc:
41
- return _error_response(exc)