# Directory Structure
```
├── .gitignore
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── client
│ │ └── index.ts
│ ├── client.test.ts
│ ├── index.ts
│ ├── markdown
│ │ ├── index.test.ts
│ │ └── index.ts
│ ├── server
│ │ └── index.ts
│ ├── types
│ │ ├── args.ts
│ │ ├── common.ts
│ │ ├── index.ts
│ │ ├── responses.ts
│ │ └── schemas.ts
│ └── utils
│ └── index.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | build/
3 | *.log
4 | .env*
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Notion MCP Server
2 |
3 | MCP Server for the Notion API, enabling LLM to interact with Notion workspaces. Additionally, it employs Markdown conversion to reduce context size when communicating with LLMs, optimizing token usage and making interactions more efficient.
4 |
5 | ## Setup
6 |
7 | Here is a detailed explanation of the steps mentioned above in the following articles:
8 |
9 | - English Version: https://dev.to/suekou/operating-notion-via-claude-desktop-using-mcp-c0h
10 | - Japanese Version: https://qiita.com/suekou/items/44c864583f5e3e6325d9
11 |
12 | 1. **Create a Notion Integration**:
13 |
14 | - Visit the [Notion Your Integrations page](https://www.notion.so/profile/integrations).
15 | - Click "New Integration".
16 | - Name your integration and select appropriate permissions (e.g., "Read content", "Update content").
17 |
18 | 2. **Retrieve the Secret Key**:
19 |
20 | - Copy the "Internal Integration Token" from your integration.
21 | - This token will be used for authentication.
22 |
23 | 3. **Add the Integration to Your Workspace**:
24 |
25 | - Open the page or database you want the integration to access in Notion.
26 | - Click the "···" button in the top right corner.
27 | - Click the "Connections" button, and select the the integration you created in step 1 above.
28 |
29 | 4. **Configure Claude Desktop**:
30 | Add the following to your `claude_desktop_config.json`:
31 |
32 | ```json
33 | {
34 | "mcpServers": {
35 | "notion": {
36 | "command": "npx",
37 | "args": ["-y", "@suekou/mcp-notion-server"],
38 | "env": {
39 | "NOTION_API_TOKEN": "your-integration-token"
40 | }
41 | }
42 | }
43 | }
44 | ```
45 |
46 | or
47 |
48 | ```json
49 | {
50 | "mcpServers": {
51 | "notion": {
52 | "command": "node",
53 | "args": ["your-built-file-path"],
54 | "env": {
55 | "NOTION_API_TOKEN": "your-integration-token"
56 | }
57 | }
58 | }
59 | }
60 | ```
61 |
62 | ## Environment Variables
63 |
64 | - `NOTION_API_TOKEN` (required): Your Notion API integration token.
65 | - `NOTION_MARKDOWN_CONVERSION`: Set to "true" to enable experimental Markdown conversion. This can significantly reduce token consumption when viewing content, but may cause issues when trying to edit page content.
66 |
67 | ## Command Line Arguments
68 |
69 | - `--enabledTools`: Comma-separated list of tools to enable (e.g. "notion_retrieve_page,notion_query_database"). When specified, only the listed tools will be available. If not specified, all tools are enabled.
70 |
71 | Read-only tools example (copy-paste friendly):
72 |
73 | ```bash
74 | node build/index.js --enabledTools=notion_retrieve_block,notion_retrieve_block_children,notion_retrieve_page,notion_query_database,notion_retrieve_database,notion_search,notion_list_all_users,notion_retrieve_user,notion_retrieve_bot_user,notion_retrieve_comments
75 | ```
76 |
77 | ## Advanced Configuration
78 |
79 | ### Markdown Conversion
80 |
81 | By default, all responses are returned in JSON format. You can enable experimental Markdown conversion to reduce token consumption:
82 |
83 | ```json
84 | {
85 | "mcpServers": {
86 | "notion": {
87 | "command": "npx",
88 | "args": ["-y", "@suekou/mcp-notion-server"],
89 | "env": {
90 | "NOTION_API_TOKEN": "your-integration-token",
91 | "NOTION_MARKDOWN_CONVERSION": "true"
92 | }
93 | }
94 | }
95 | }
96 | ```
97 |
98 | or
99 |
100 | ```json
101 | {
102 | "mcpServers": {
103 | "notion": {
104 | "command": "node",
105 | "args": ["your-built-file-path"],
106 | "env": {
107 | "NOTION_API_TOKEN": "your-integration-token",
108 | "NOTION_MARKDOWN_CONVERSION": "true"
109 | }
110 | }
111 | }
112 | }
113 | ```
114 |
115 | When `NOTION_MARKDOWN_CONVERSION` is set to `"true"`, responses will be converted to Markdown format (when `format` parameter is set to `"markdown"`), making them more human-readable and significantly reducing token consumption. However, since this feature is experimental, it may cause issues when trying to edit page content as the original structure is lost in conversion.
116 |
117 | You can control the format on a per-request basis by setting the `format` parameter to either `"json"` or `"markdown"` in your tool calls:
118 |
119 | - Use `"markdown"` for better readability when only viewing content
120 | - Use `"json"` when you need to modify the returned content
121 |
122 | ## Troubleshooting
123 |
124 | If you encounter permission errors:
125 |
126 | 1. Ensure the integration has the required permissions.
127 | 2. Verify that the integration is invited to the relevant pages or databases.
128 | 3. Confirm the token and configuration are correctly set in `claude_desktop_config.json`.
129 |
130 | ## Project Structure
131 |
132 | The project is organized in a modular way to improve maintainability and readability:
133 |
134 | ```
135 | ./
136 | ├── src/
137 | │ ├── index.ts # Entry point and command-line handling
138 | │ ├── client/
139 | │ │ └── index.ts # NotionClientWrapper class for API interactions
140 | │ ├── server/
141 | │ │ └── index.ts # MCP server setup and request handling
142 | │ ├── types/
143 | │ │ ├── index.ts # Type exports
144 | │ │ ├── args.ts # Tool argument interfaces
145 | │ │ ├── common.ts # Common schema definitions
146 | │ │ ├── responses.ts # API response type definitions
147 | │ │ └── schemas.ts # Tool schema definitions
148 | │ ├── utils/
149 | │ │ └── index.ts # Utility functions
150 | │ └── markdown/
151 | │ └── index.ts # Markdown conversion utilities
152 | ```
153 |
154 | ### Directory Descriptions
155 |
156 | - **index.ts**: Application entry point. Parses command-line arguments and starts the server.
157 | - **client/**: Module responsible for communication with the Notion API.
158 | - **index.ts**: NotionClientWrapper class implements all API calls.
159 | - **server/**: MCP server implementation.
160 | - **index.ts**: Processes requests received from Claude and calls appropriate client methods.
161 | - **types/**: Type definition module.
162 | - **index.ts**: Exports for all types.
163 | - **args.ts**: Interface definitions for tool arguments.
164 | - **common.ts**: Definitions for common schemas (ID formats, rich text, etc.).
165 | - **responses.ts**: Type definitions for Notion API responses.
166 | - **schemas.ts**: Definitions for MCP tool schemas.
167 | - **utils/**: Utility functions.
168 | - **index.ts**: Functions like filtering enabled tools.
169 | - **markdown/**: Markdown conversion functionality.
170 | - **index.ts**: Logic for converting JSON responses to Markdown format.
171 |
172 | ## Tools
173 |
174 | All tools support the following optional parameter:
175 |
176 | - `format` (string, "json" or "markdown", default: "markdown"): Controls the response format. Use "markdown" for human-readable output, "json" for programmatic access to the original data structure. Note: Markdown conversion only works when the `NOTION_MARKDOWN_CONVERSION` environment variable is set to "true".
177 |
178 | 1. `notion_append_block_children`
179 |
180 | - Append child blocks to a parent block.
181 | - Required inputs:
182 | - `block_id` (string): The ID of the parent block.
183 | - `children` (array): Array of block objects to append.
184 | - Returns: Information about the appended blocks.
185 |
186 | 2. `notion_retrieve_block`
187 |
188 | - Retrieve information about a specific block.
189 | - Required inputs:
190 | - `block_id` (string): The ID of the block to retrieve.
191 | - Returns: Detailed information about the block.
192 |
193 | 3. `notion_retrieve_block_children`
194 |
195 | - Retrieve the children of a specific block.
196 | - Required inputs:
197 | - `block_id` (string): The ID of the parent block.
198 | - Optional inputs:
199 | - `start_cursor` (string): Cursor for the next page of results.
200 | - `page_size` (number, default: 100, max: 100): Number of blocks to retrieve.
201 | - Returns: List of child blocks.
202 |
203 | 4. `notion_delete_block`
204 |
205 | - Delete a specific block.
206 | - Required inputs:
207 | - `block_id` (string): The ID of the block to delete.
208 | - Returns: Confirmation of the deletion.
209 |
210 | 5. `notion_retrieve_page`
211 |
212 | - Retrieve information about a specific page.
213 | - Required inputs:
214 | - `page_id` (string): The ID of the page to retrieve.
215 | - Returns: Detailed information about the page.
216 |
217 | 6. `notion_update_page_properties`
218 |
219 | - Update properties of a page.
220 | - Required inputs:
221 | - `page_id` (string): The ID of the page to update.
222 | - `properties` (object): Properties to update.
223 | - Returns: Information about the updated page.
224 |
225 | 7. `notion_create_database`
226 |
227 | - Create a new database.
228 | - Required inputs:
229 | - `parent` (object): Parent object of the database.
230 | - `properties` (object): Property schema of the database.
231 | - Optional inputs:
232 | - `title` (array): Title of the database as a rich text array.
233 | - Returns: Information about the created database.
234 |
235 | 8. `notion_query_database`
236 |
237 | - Query a database.
238 | - Required inputs:
239 | - `database_id` (string): The ID of the database to query.
240 | - Optional inputs:
241 | - `filter` (object): Filter conditions.
242 | - `sorts` (array): Sorting conditions.
243 | - `start_cursor` (string): Cursor for the next page of results.
244 | - `page_size` (number, default: 100, max: 100): Number of results to retrieve.
245 | - Returns: List of results from the query.
246 |
247 | 9. `notion_retrieve_database`
248 |
249 | - Retrieve information about a specific database.
250 | - Required inputs:
251 | - `database_id` (string): The ID of the database to retrieve.
252 | - Returns: Detailed information about the database.
253 |
254 | 10. `notion_update_database`
255 |
256 | - Update information about a database.
257 | - Required inputs:
258 | - `database_id` (string): The ID of the database to update.
259 | - Optional inputs:
260 | - `title` (array): New title for the database.
261 | - `description` (array): New description for the database.
262 | - `properties` (object): Updated property schema.
263 | - Returns: Information about the updated database.
264 |
265 | 11. `notion_create_database_item`
266 |
267 | - Create a new item in a Notion database.
268 | - Required inputs:
269 | - `database_id` (string): The ID of the database to add the item to.
270 | - `properties` (object): The properties of the new item. These should match the database schema.
271 | - Returns: Information about the newly created item.
272 |
273 | 12. `notion_search`
274 |
275 | - Search pages or databases by title.
276 | - Optional inputs:
277 | - `query` (string): Text to search for in page or database titles.
278 | - `filter` (object): Criteria to limit results to either only pages or only databases.
279 | - `sort` (object): Criteria to sort the results
280 | - `start_cursor` (string): Pagination start cursor.
281 | - `page_size` (number, default: 100, max: 100): Number of results to retrieve.
282 | - Returns: List of matching pages or databases.
283 |
284 | 13. `notion_list_all_users`
285 |
286 | - List all users in the Notion workspace.
287 | - Note: This function requires upgrading to the Notion Enterprise plan and using an Organization API key to avoid permission errors.
288 | - Optional inputs:
289 | - start_cursor (string): Pagination start cursor for listing users.
290 | - page_size (number, max: 100): Number of users to retrieve.
291 | - Returns: A paginated list of all users in the workspace.
292 |
293 | 14. `notion_retrieve_user`
294 |
295 | - Retrieve a specific user by user_id in Notion.
296 | - Note: This function requires upgrading to the Notion Enterprise plan and using an Organization API key to avoid permission errors.
297 | - Required inputs:
298 | - user_id (string): The ID of the user to retrieve.
299 | - Returns: Detailed information about the specified user.
300 |
301 | 15. `notion_retrieve_bot_user`
302 |
303 | - Retrieve the bot user associated with the current token in Notion.
304 | - Returns: Information about the bot user, including details of the person who authorized the integration.
305 |
306 | 16. `notion_create_comment`
307 |
308 | - Create a comment in Notion.
309 | - Requires the integration to have 'insert comment' capabilities.
310 | - Either specify a `parent` object with a `page_id` or a `discussion_id`, but not both.
311 | - Required inputs:
312 | - `rich_text` (array): Array of rich text objects representing the comment content.
313 | - Optional inputs:
314 | - `parent` (object): Must include `page_id` if used.
315 | - `discussion_id` (string): An existing discussion thread ID.
316 | - Returns: Information about the created comment.
317 |
318 | 17. `notion_retrieve_comments`
319 | - Retrieve a list of unresolved comments from a Notion page or block.
320 | - Requires the integration to have 'read comment' capabilities.
321 | - Required inputs:
322 | - `block_id` (string): The ID of the block or page whose comments you want to retrieve.
323 | - Optional inputs:
324 | - `start_cursor` (string): Pagination start cursor.
325 | - `page_size` (number, max: 100): Number of comments to retrieve.
326 | - Returns: A paginated list of comments associated with the specified block or page.
327 |
328 | ## License
329 |
330 | This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.
331 |
```
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Type definitions for Notion API
3 | *
4 | * This file re-exports all types from more specialized files
5 | */
6 |
7 | // Export all types
8 | export * from "./common.js";
9 | export * from "./args.js";
10 | export * from "./schemas.js";
11 | export * from "./responses.js";
12 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Utility functions
3 | */
4 |
5 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
6 |
7 | /**
8 | * Filter tools based on enabledTools parameter
9 | */
10 | export function filterTools(
11 | tools: Tool[],
12 | enabledToolsSet: Set<string> = new Set()
13 | ): Tool[] {
14 | if (enabledToolsSet.size === 0) return tools;
15 | return tools.filter((tool) => enabledToolsSet.has(tool.name));
16 | }
17 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@suekou/mcp-notion-server",
3 | "version": "1.2.4",
4 | "description": "MCP server for interacting with Notion API based on Node",
5 | "license": "MIT",
6 | "author": "Kosuke Suenaga (https://github.com/suekou/mcp-notion-server)",
7 | "type": "module",
8 | "main": "build/index.js",
9 | "bin": {
10 | "mcp-notion-server": "build/index.js"
11 | },
12 | "files": [
13 | "build",
14 | "Readme.md"
15 | ],
16 | "scripts": {
17 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
18 | "prepare": "npm run build",
19 | "watch": "tsc --watch",
20 | "inspector": "npx @modelcontextprotocol/inspector build/index.js",
21 | "test": "vitest run",
22 | "test:watch": "vitest"
23 | },
24 | "dependencies": {
25 | "@modelcontextprotocol/sdk": "^1.11.2",
26 | "node-fetch": "^2.7.0",
27 | "vitest": "3.0.9",
28 | "yargs": "^17.7.2"
29 | },
30 | "devDependencies": {
31 | "@types/node": "^20.11.24",
32 | "@types/node-fetch": "^2.6.12",
33 | "@types/yargs": "^17.0.33",
34 | "typescript": "^5.3.3"
35 | }
36 | }
37 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | /**
3 | * All API endpoints support both JSON and Markdown response formats.
4 | * Set the "format" parameter to "json" or "markdown" (default is "markdown").
5 | * - Use "markdown" for human-readable output when only reading content
6 | * - Use "json" when you need to process or modify the data programmatically
7 | *
8 | * Command-line Arguments:
9 | * --enabledTools: Comma-separated list of tools to enable (e.g. "notion_retrieve_page,notion_query_database")
10 | *
11 | * Environment Variables:
12 | * - NOTION_API_TOKEN: Required. Your Notion API integration token.
13 | * - NOTION_MARKDOWN_CONVERSION: Optional. Set to "true" to enable
14 | * experimental Markdown conversion. If not set or set to any other value,
15 | * all responses will be in JSON format regardless of the "format" parameter.
16 | */
17 | import yargs from "yargs";
18 | import { hideBin } from "yargs/helpers";
19 | import { startServer } from "./server/index.js";
20 |
21 | // Parse command line arguments
22 | const argv = yargs(hideBin(process.argv))
23 | .option("enabledTools", {
24 | type: "string",
25 | description: "Comma-separated list of tools to enable",
26 | })
27 | .parseSync();
28 |
29 | const enabledToolsSet = new Set(
30 | argv.enabledTools ? argv.enabledTools.split(",") : []
31 | );
32 |
33 | // if test environment, do not execute main()
34 | if (process.env.NODE_ENV !== "test" && process.env.VITEST !== "true") {
35 | main().catch((error) => {
36 | console.error("Fatal error in main():", error);
37 | process.exit(1);
38 | });
39 | }
40 |
41 | async function main() {
42 | const notionToken = process.env.NOTION_API_TOKEN;
43 | const enableMarkdownConversion =
44 | process.env.NOTION_MARKDOWN_CONVERSION === "true";
45 |
46 | if (!notionToken) {
47 | console.error("Please set NOTION_API_TOKEN environment variable");
48 | process.exit(1);
49 | }
50 |
51 | await startServer(notionToken, enabledToolsSet, enableMarkdownConversion);
52 | }
53 |
```
--------------------------------------------------------------------------------
/src/types/args.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Type definitions for tool arguments
3 | */
4 |
5 | import { RichTextItemResponse, BlockResponse } from "./responses.js";
6 |
7 | // Blocks
8 | export interface AppendBlockChildrenArgs {
9 | block_id: string;
10 | children: Partial<BlockResponse>[];
11 | after?: string;
12 | format?: "json" | "markdown";
13 | }
14 |
15 | export interface RetrieveBlockArgs {
16 | block_id: string;
17 | format?: "json" | "markdown";
18 | }
19 |
20 | export interface RetrieveBlockChildrenArgs {
21 | block_id: string;
22 | start_cursor?: string;
23 | page_size?: number;
24 | format?: "json" | "markdown";
25 | }
26 |
27 | export interface DeleteBlockArgs {
28 | block_id: string;
29 | format?: "json" | "markdown";
30 | }
31 |
32 | export interface UpdateBlockArgs {
33 | block_id: string;
34 | block: Partial<BlockResponse>;
35 | format?: "json" | "markdown";
36 | }
37 |
38 | // Pages
39 | export interface RetrievePageArgs {
40 | page_id: string;
41 | format?: "json" | "markdown";
42 | }
43 |
44 | export interface UpdatePagePropertiesArgs {
45 | page_id: string;
46 | properties: Record<string, any>;
47 | format?: "json" | "markdown";
48 | }
49 |
50 | // Users
51 | export interface ListAllUsersArgs {
52 | start_cursor?: string;
53 | page_size?: number;
54 | format?: "json" | "markdown";
55 | }
56 |
57 | export interface RetrieveUserArgs {
58 | user_id: string;
59 | format?: "json" | "markdown";
60 | }
61 |
62 | export interface RetrieveBotUserArgs {
63 | random_string: string;
64 | format?: "json" | "markdown";
65 | }
66 |
67 | // Databases
68 | export interface CreateDatabaseArgs {
69 | parent: {
70 | type: string;
71 | page_id?: string;
72 | database_id?: string;
73 | workspace?: boolean;
74 | };
75 | title?: RichTextItemResponse[];
76 | properties: Record<string, any>;
77 | format?: "json" | "markdown";
78 | }
79 |
80 | export interface QueryDatabaseArgs {
81 | database_id: string;
82 | filter?: Record<string, any>;
83 | sorts?: Array<{
84 | property?: string;
85 | timestamp?: string;
86 | direction: "ascending" | "descending";
87 | }>;
88 | start_cursor?: string;
89 | page_size?: number;
90 | format?: "json" | "markdown";
91 | }
92 |
93 | export interface RetrieveDatabaseArgs {
94 | database_id: string;
95 | format?: "json" | "markdown";
96 | }
97 |
98 | export interface UpdateDatabaseArgs {
99 | database_id: string;
100 | title?: RichTextItemResponse[];
101 | description?: RichTextItemResponse[];
102 | properties?: Record<string, any>;
103 | format?: "json" | "markdown";
104 | }
105 |
106 | export interface CreateDatabaseItemArgs {
107 | database_id: string;
108 | properties: Record<string, any>;
109 | format?: "json" | "markdown";
110 | }
111 |
112 | // Comments
113 | export interface CreateCommentArgs {
114 | parent?: { page_id: string };
115 | discussion_id?: string;
116 | rich_text: RichTextItemResponse[];
117 | format?: "json" | "markdown";
118 | }
119 |
120 | export interface RetrieveCommentsArgs {
121 | block_id: string;
122 | start_cursor?: string;
123 | page_size?: number;
124 | format?: "json" | "markdown";
125 | }
126 |
127 | // Search
128 | export interface SearchArgs {
129 | query?: string;
130 | filter?: { property: string; value: string };
131 | sort?: {
132 | direction: "ascending" | "descending";
133 | timestamp: "last_edited_time";
134 | };
135 | start_cursor?: string;
136 | page_size?: number;
137 | format?: "json" | "markdown";
138 | }
139 |
```
--------------------------------------------------------------------------------
/src/types/responses.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Type definitions for Notion API responses
3 | */
4 |
5 | export type NotionObjectType =
6 | | "page"
7 | | "database"
8 | | "block"
9 | | "list"
10 | | "user"
11 | | "comment";
12 |
13 | export type RichTextItemResponse = {
14 | type: "text" | "mention" | "equation";
15 | text?: {
16 | content: string;
17 | link?: {
18 | url: string;
19 | } | null;
20 | };
21 | mention?: {
22 | type:
23 | | "database"
24 | | "date"
25 | | "link_preview"
26 | | "page"
27 | | "template_mention"
28 | | "user";
29 | [key: string]: any;
30 | };
31 | annotations?: {
32 | bold: boolean;
33 | italic: boolean;
34 | strikethrough: boolean;
35 | underline: boolean;
36 | code: boolean;
37 | color: string;
38 | };
39 | plain_text?: string;
40 | href?: string | null;
41 | equation?: {
42 | expression: string;
43 | };
44 | };
45 |
46 | export type BlockType =
47 | | "paragraph"
48 | | "heading_1"
49 | | "heading_2"
50 | | "heading_3"
51 | | "bulleted_list_item"
52 | | "numbered_list_item"
53 | | "to_do"
54 | | "toggle"
55 | | "child_page"
56 | | "child_database"
57 | | "embed"
58 | | "callout"
59 | | "quote"
60 | | "equation"
61 | | "divider"
62 | | "table_of_contents"
63 | | "column"
64 | | "column_list"
65 | | "link_preview"
66 | | "synced_block"
67 | | "template"
68 | | "link_to_page"
69 | | "audio"
70 | | "bookmark"
71 | | "breadcrumb"
72 | | "code"
73 | | "file"
74 | | "image"
75 | | "pdf"
76 | | "video"
77 | | "unsupported"
78 | | string;
79 |
80 | export type BlockResponse = {
81 | object: "block";
82 | id: string;
83 | type: BlockType;
84 | created_time: string;
85 | last_edited_time: string;
86 | has_children?: boolean;
87 | archived?: boolean;
88 | [key: string]: any;
89 | };
90 |
91 | export type PageResponse = {
92 | object: "page";
93 | id: string;
94 | created_time: string;
95 | last_edited_time: string;
96 | created_by?: {
97 | object: "user";
98 | id: string;
99 | };
100 | last_edited_by?: {
101 | object: "user";
102 | id: string;
103 | };
104 | cover?: {
105 | type: string;
106 | [key: string]: any;
107 | } | null;
108 | icon?: {
109 | type: string;
110 | [key: string]: any;
111 | } | null;
112 | archived?: boolean;
113 | in_trash?: boolean;
114 | url?: string;
115 | public_url?: string;
116 | parent: {
117 | type: "database_id" | "page_id" | "workspace";
118 | database_id?: string;
119 | page_id?: string;
120 | };
121 | properties: Record<string, PageProperty>;
122 | };
123 |
124 | export type PageProperty = {
125 | id: string;
126 | type: string;
127 | [key: string]: any;
128 | };
129 |
130 | export type DatabaseResponse = {
131 | object: "database";
132 | id: string;
133 | created_time: string;
134 | last_edited_time: string;
135 | title: RichTextItemResponse[];
136 | description?: RichTextItemResponse[];
137 | url?: string;
138 | icon?: {
139 | type: string;
140 | emoji?: string;
141 | [key: string]: any;
142 | } | null;
143 | cover?: {
144 | type: string;
145 | [key: string]: any;
146 | } | null;
147 | properties: Record<string, DatabasePropertyConfig>;
148 | parent?: {
149 | type: string;
150 | page_id?: string;
151 | workspace?: boolean;
152 | };
153 | archived?: boolean;
154 | is_inline?: boolean;
155 | };
156 |
157 | export type DatabasePropertyConfig = {
158 | id: string;
159 | name: string;
160 | type: string;
161 | [key: string]: any;
162 | };
163 |
164 | export type ListResponse = {
165 | object: "list";
166 | results: Array<
167 | | PageResponse
168 | | DatabaseResponse
169 | | BlockResponse
170 | | UserResponse
171 | | CommentResponse
172 | >;
173 | next_cursor: string | null;
174 | has_more: boolean;
175 | type?: string;
176 | page_or_database?: Record<string, any>;
177 | };
178 |
179 | export type UserResponse = {
180 | object: "user";
181 | id: string;
182 | name?: string;
183 | avatar_url?: string | null;
184 | type?: "person" | "bot";
185 | person?: {
186 | email: string;
187 | };
188 | bot?: Record<string, any>;
189 | };
190 |
191 | export type CommentResponse = {
192 | object: "comment";
193 | id: string;
194 | parent: {
195 | type: "page_id" | "block_id";
196 | page_id?: string;
197 | block_id?: string;
198 | };
199 | discussion_id: string;
200 | created_time: string;
201 | last_edited_time: string;
202 | created_by: {
203 | object: "user";
204 | id: string;
205 | };
206 | rich_text: RichTextItemResponse[];
207 | };
208 |
209 | export type NotionResponse =
210 | | PageResponse
211 | | DatabaseResponse
212 | | BlockResponse
213 | | ListResponse
214 | | UserResponse
215 | | CommentResponse;
216 |
```
--------------------------------------------------------------------------------
/src/client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { expect, test, describe, vi, beforeEach } from "vitest";
2 | import { NotionClientWrapper } from "./client/index.js";
3 | import { PageResponse } from "./types/index.js";
4 | import { filterTools } from "./utils/index.js";
5 | import fetch from "node-fetch";
6 |
7 | vi.mock("./markdown/index.js", () => ({
8 | convertToMarkdown: vi.fn().mockReturnValue("# Test"),
9 | }));
10 |
11 | vi.mock("node-fetch", () => {
12 | return {
13 | default: vi.fn(),
14 | };
15 | });
16 |
17 | // Mock tool list
18 | const mockInputSchema = { type: "object" as const };
19 | const mockTools = [
20 | {
21 | name: "notion_retrieve_block",
22 | inputSchema: mockInputSchema,
23 | },
24 | {
25 | name: "notion_retrieve_page",
26 | inputSchema: mockInputSchema,
27 | },
28 | {
29 | name: "notion_query_database",
30 | inputSchema: mockInputSchema,
31 | },
32 | ];
33 |
34 | describe("NotionClientWrapper", () => {
35 | let wrapper: any;
36 |
37 | beforeEach(() => {
38 | // Reset mocks
39 | vi.resetAllMocks();
40 |
41 | // Create client wrapper with test token
42 | wrapper = new NotionClientWrapper("test-token");
43 |
44 | // Mock fetch to return JSON
45 | (fetch as any).mockImplementation(() =>
46 | Promise.resolve({
47 | json: () => Promise.resolve({ success: true }),
48 | })
49 | );
50 | });
51 |
52 | test("should initialize with correct headers", () => {
53 | expect((wrapper as any).headers).toEqual({
54 | Authorization: "Bearer test-token",
55 | "Content-Type": "application/json",
56 | "Notion-Version": "2022-06-28",
57 | });
58 | });
59 |
60 | test("should call appendBlockChildren with correct parameters", async () => {
61 | const blockId = "block123";
62 | const children = [{ type: "paragraph" }];
63 |
64 | await wrapper.appendBlockChildren(blockId, children);
65 |
66 | expect(fetch).toHaveBeenCalledWith(
67 | `https://api.notion.com/v1/blocks/${blockId}/children`,
68 | {
69 | method: "PATCH",
70 | headers: (wrapper as any).headers,
71 | body: JSON.stringify({ children }),
72 | }
73 | );
74 | });
75 |
76 | test("should call retrieveBlock with correct parameters", async () => {
77 | const blockId = "block123";
78 |
79 | await wrapper.retrieveBlock(blockId);
80 |
81 | expect(fetch).toHaveBeenCalledWith(
82 | `https://api.notion.com/v1/blocks/${blockId}`,
83 | {
84 | method: "GET",
85 | headers: (wrapper as any).headers,
86 | }
87 | );
88 | });
89 |
90 | test("should call retrieveBlockChildren with pagination parameters", async () => {
91 | const blockId = "block123";
92 | const startCursor = "cursor123";
93 | const pageSize = 10;
94 |
95 | await wrapper.retrieveBlockChildren(blockId, startCursor, pageSize);
96 |
97 | expect(fetch).toHaveBeenCalledWith(
98 | `https://api.notion.com/v1/blocks/${blockId}/children?start_cursor=${startCursor}&page_size=${pageSize}`,
99 | {
100 | method: "GET",
101 | headers: (wrapper as any).headers,
102 | }
103 | );
104 | });
105 |
106 | test("should call retrievePage with correct parameters", async () => {
107 | const pageId = "page123";
108 |
109 | await wrapper.retrievePage(pageId);
110 |
111 | expect(fetch).toHaveBeenCalledWith(
112 | `https://api.notion.com/v1/pages/${pageId}`,
113 | {
114 | method: "GET",
115 | headers: (wrapper as any).headers,
116 | }
117 | );
118 | });
119 |
120 | test("should call updatePageProperties with correct parameters", async () => {
121 | const pageId = "page123";
122 | const properties = {
123 | title: { title: [{ text: { content: "New Title" } }] },
124 | };
125 |
126 | await wrapper.updatePageProperties(pageId, properties);
127 |
128 | expect(fetch).toHaveBeenCalledWith(
129 | `https://api.notion.com/v1/pages/${pageId}`,
130 | {
131 | method: "PATCH",
132 | headers: (wrapper as any).headers,
133 | body: JSON.stringify({ properties }),
134 | }
135 | );
136 | });
137 |
138 | test("should call queryDatabase with correct parameters", async () => {
139 | const databaseId = "db123";
140 | const filter = { property: "Status", equals: "Done" };
141 | const sorts = [{ property: "Due Date", direction: "ascending" }];
142 |
143 | await wrapper.queryDatabase(databaseId, filter, sorts);
144 |
145 | expect(fetch).toHaveBeenCalledWith(
146 | `https://api.notion.com/v1/databases/${databaseId}/query`,
147 | {
148 | method: "POST",
149 | headers: (wrapper as any).headers,
150 | body: JSON.stringify({ filter, sorts }),
151 | }
152 | );
153 | });
154 |
155 | test("should call search with correct parameters", async () => {
156 | const query = "test query";
157 | const filter = { property: "object", value: "page" };
158 |
159 | await wrapper.search(query, filter);
160 |
161 | expect(fetch).toHaveBeenCalledWith(
162 | "https://api.notion.com/v1/search",
163 | {
164 | method: "POST",
165 | headers: (wrapper as any).headers,
166 | body: JSON.stringify({ query, filter }),
167 | }
168 | );
169 | });
170 |
171 | test("should call toMarkdown method correctly", async () => {
172 | const { convertToMarkdown } = await import("./markdown/index.js");
173 |
174 | const response: PageResponse = {
175 | object: "page",
176 | id: "test",
177 | created_time: "2021-01-01T00:00:00.000Z",
178 | last_edited_time: "2021-01-01T00:00:00.000Z",
179 | parent: {
180 | type: "workspace",
181 | },
182 | properties: {},
183 | };
184 | await wrapper.toMarkdown(response);
185 |
186 | expect(convertToMarkdown).toHaveBeenCalledWith(response);
187 | });
188 |
189 | describe("filterTools", () => {
190 | test("should return all tools when no filter specified", () => {
191 | const result = filterTools(mockTools);
192 | expect(result).toEqual(mockTools);
193 | });
194 |
195 | test("should filter tools based on enabledTools", () => {
196 | const enabledToolsSet = new Set([
197 | "notion_retrieve_block",
198 | "notion_query_database",
199 | ]);
200 | const result = filterTools(mockTools, enabledToolsSet);
201 | expect(result).toEqual([
202 | { name: "notion_retrieve_block", inputSchema: mockInputSchema },
203 | { name: "notion_query_database", inputSchema: mockInputSchema },
204 | ]);
205 | });
206 |
207 | test("should return empty array when no tools match", () => {
208 | const enabledToolsSet = new Set(["non_existent_tool"]);
209 | const result = filterTools(mockTools, enabledToolsSet);
210 | expect(result).toEqual([]);
211 | });
212 | });
213 | });
214 |
```
--------------------------------------------------------------------------------
/src/client/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Notion API client wrapper
3 | */
4 |
5 | import { convertToMarkdown } from "../markdown/index.js";
6 | import {
7 | NotionResponse,
8 | BlockResponse,
9 | PageResponse,
10 | DatabaseResponse,
11 | ListResponse,
12 | UserResponse,
13 | CommentResponse,
14 | RichTextItemResponse,
15 | CreateDatabaseArgs,
16 | } from "../types/index.js";
17 | import fetch from "node-fetch";
18 |
19 | export class NotionClientWrapper {
20 | private notionToken: string;
21 | private baseUrl: string = "https://api.notion.com/v1";
22 | private headers: { [key: string]: string };
23 |
24 | constructor(token: string) {
25 | this.notionToken = token;
26 | this.headers = {
27 | Authorization: `Bearer ${this.notionToken}`,
28 | "Content-Type": "application/json",
29 | "Notion-Version": "2022-06-28",
30 | };
31 | }
32 |
33 | async appendBlockChildren(
34 | block_id: string,
35 | children: Partial<BlockResponse>[]
36 | ): Promise<BlockResponse> {
37 | const body = { children };
38 |
39 | const response = await fetch(
40 | `${this.baseUrl}/blocks/${block_id}/children`,
41 | {
42 | method: "PATCH",
43 | headers: this.headers,
44 | body: JSON.stringify(body),
45 | }
46 | );
47 |
48 | return response.json();
49 | }
50 |
51 | async retrieveBlock(block_id: string): Promise<BlockResponse> {
52 | const response = await fetch(`${this.baseUrl}/blocks/${block_id}`, {
53 | method: "GET",
54 | headers: this.headers,
55 | });
56 |
57 | return response.json();
58 | }
59 |
60 | async retrieveBlockChildren(
61 | block_id: string,
62 | start_cursor?: string,
63 | page_size?: number
64 | ): Promise<ListResponse> {
65 | const params = new URLSearchParams();
66 | if (start_cursor) params.append("start_cursor", start_cursor);
67 | if (page_size) params.append("page_size", page_size.toString());
68 |
69 | const response = await fetch(
70 | `${this.baseUrl}/blocks/${block_id}/children?${params}`,
71 | {
72 | method: "GET",
73 | headers: this.headers,
74 | }
75 | );
76 |
77 | return response.json();
78 | }
79 |
80 | async deleteBlock(block_id: string): Promise<BlockResponse> {
81 | const response = await fetch(`${this.baseUrl}/blocks/${block_id}`, {
82 | method: "DELETE",
83 | headers: this.headers,
84 | });
85 |
86 | return response.json();
87 | }
88 |
89 | async updateBlock(
90 | block_id: string,
91 | block: Partial<BlockResponse>
92 | ): Promise<BlockResponse> {
93 | const response = await fetch(`${this.baseUrl}/blocks/${block_id}`, {
94 | method: "PATCH",
95 | headers: this.headers,
96 | body: JSON.stringify(block),
97 | });
98 |
99 | return response.json();
100 | }
101 |
102 | async retrievePage(page_id: string): Promise<PageResponse> {
103 | const response = await fetch(`${this.baseUrl}/pages/${page_id}`, {
104 | method: "GET",
105 | headers: this.headers,
106 | });
107 |
108 | return response.json();
109 | }
110 |
111 | async updatePageProperties(
112 | page_id: string,
113 | properties: Record<string, any>
114 | ): Promise<PageResponse> {
115 | const body = { properties };
116 |
117 | const response = await fetch(`${this.baseUrl}/pages/${page_id}`, {
118 | method: "PATCH",
119 | headers: this.headers,
120 | body: JSON.stringify(body),
121 | });
122 |
123 | return response.json();
124 | }
125 |
126 | async listAllUsers(
127 | start_cursor?: string,
128 | page_size?: number
129 | ): Promise<ListResponse> {
130 | const params = new URLSearchParams();
131 | if (start_cursor) params.append("start_cursor", start_cursor);
132 | if (page_size) params.append("page_size", page_size.toString());
133 |
134 | const response = await fetch(`${this.baseUrl}/users?${params.toString()}`, {
135 | method: "GET",
136 | headers: this.headers,
137 | });
138 | return response.json();
139 | }
140 |
141 | async retrieveUser(user_id: string): Promise<UserResponse> {
142 | const response = await fetch(`${this.baseUrl}/users/${user_id}`, {
143 | method: "GET",
144 | headers: this.headers,
145 | });
146 | return response.json();
147 | }
148 |
149 | async retrieveBotUser(): Promise<UserResponse> {
150 | const response = await fetch(`${this.baseUrl}/users/me`, {
151 | method: "GET",
152 | headers: this.headers,
153 | });
154 | return response.json();
155 | }
156 |
157 | async createDatabase(
158 | parent: CreateDatabaseArgs["parent"],
159 | properties: Record<string, any>,
160 | title?: RichTextItemResponse[]
161 | ): Promise<DatabaseResponse> {
162 | const body = { parent, title, properties };
163 |
164 | const response = await fetch(`${this.baseUrl}/databases`, {
165 | method: "POST",
166 | headers: this.headers,
167 | body: JSON.stringify(body),
168 | });
169 |
170 | return response.json();
171 | }
172 |
173 | async queryDatabase(
174 | database_id: string,
175 | filter?: Record<string, any>,
176 | sorts?: Array<{
177 | property?: string;
178 | timestamp?: string;
179 | direction: "ascending" | "descending";
180 | }>,
181 | start_cursor?: string,
182 | page_size?: number
183 | ): Promise<ListResponse> {
184 | const body: Record<string, any> = {};
185 | if (filter) body.filter = filter;
186 | if (sorts) body.sorts = sorts;
187 | if (start_cursor) body.start_cursor = start_cursor;
188 | if (page_size) body.page_size = page_size;
189 |
190 | const response = await fetch(
191 | `${this.baseUrl}/databases/${database_id}/query`,
192 | {
193 | method: "POST",
194 | headers: this.headers,
195 | body: JSON.stringify(body),
196 | }
197 | );
198 |
199 | return response.json();
200 | }
201 |
202 | async retrieveDatabase(database_id: string): Promise<DatabaseResponse> {
203 | const response = await fetch(`${this.baseUrl}/databases/${database_id}`, {
204 | method: "GET",
205 | headers: this.headers,
206 | });
207 |
208 | return response.json();
209 | }
210 |
211 | async updateDatabase(
212 | database_id: string,
213 | title?: RichTextItemResponse[],
214 | description?: RichTextItemResponse[],
215 | properties?: Record<string, any>
216 | ): Promise<DatabaseResponse> {
217 | const body: Record<string, any> = {};
218 | if (title) body.title = title;
219 | if (description) body.description = description;
220 | if (properties) body.properties = properties;
221 |
222 | const response = await fetch(`${this.baseUrl}/databases/${database_id}`, {
223 | method: "PATCH",
224 | headers: this.headers,
225 | body: JSON.stringify(body),
226 | });
227 |
228 | return response.json();
229 | }
230 |
231 | async createDatabaseItem(
232 | database_id: string,
233 | properties: Record<string, any>
234 | ): Promise<PageResponse> {
235 | const body = {
236 | parent: { database_id },
237 | properties,
238 | };
239 |
240 | const response = await fetch(`${this.baseUrl}/pages`, {
241 | method: "POST",
242 | headers: this.headers,
243 | body: JSON.stringify(body),
244 | });
245 |
246 | return response.json();
247 | }
248 |
249 | async createComment(
250 | parent?: { page_id: string },
251 | discussion_id?: string,
252 | rich_text?: RichTextItemResponse[]
253 | ): Promise<CommentResponse> {
254 | const body: Record<string, any> = { rich_text };
255 | if (parent) {
256 | body.parent = parent;
257 | }
258 | if (discussion_id) {
259 | body.discussion_id = discussion_id;
260 | }
261 |
262 | const response = await fetch(`${this.baseUrl}/comments`, {
263 | method: "POST",
264 | headers: this.headers,
265 | body: JSON.stringify(body),
266 | });
267 |
268 | return response.json();
269 | }
270 |
271 | async retrieveComments(
272 | block_id: string,
273 | start_cursor?: string,
274 | page_size?: number
275 | ): Promise<ListResponse> {
276 | const params = new URLSearchParams();
277 | params.append("block_id", block_id);
278 | if (start_cursor) params.append("start_cursor", start_cursor);
279 | if (page_size) params.append("page_size", page_size.toString());
280 |
281 | const response = await fetch(
282 | `${this.baseUrl}/comments?${params.toString()}`,
283 | {
284 | method: "GET",
285 | headers: this.headers,
286 | }
287 | );
288 |
289 | return response.json();
290 | }
291 |
292 | async search(
293 | query?: string,
294 | filter?: { property: string; value: string },
295 | sort?: {
296 | direction: "ascending" | "descending";
297 | timestamp: "last_edited_time";
298 | },
299 | start_cursor?: string,
300 | page_size?: number
301 | ): Promise<ListResponse> {
302 | const body: Record<string, any> = {};
303 | if (query) body.query = query;
304 | if (filter) body.filter = filter;
305 | if (sort) body.sort = sort;
306 | if (start_cursor) body.start_cursor = start_cursor;
307 | if (page_size) body.page_size = page_size;
308 |
309 | const response = await fetch(`${this.baseUrl}/search`, {
310 | method: "POST",
311 | headers: this.headers,
312 | body: JSON.stringify(body),
313 | });
314 |
315 | return response.json();
316 | }
317 |
318 | async toMarkdown(response: NotionResponse): Promise<string> {
319 | return convertToMarkdown(response);
320 | }
321 | }
322 |
```
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP server setup and request handling
3 | */
4 |
5 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7 | import {
8 | CallToolRequest,
9 | CallToolRequestSchema,
10 | ListToolsRequestSchema,
11 | } from "@modelcontextprotocol/sdk/types.js";
12 | import { NotionClientWrapper } from "../client/index.js";
13 | import { filterTools } from "../utils/index.js";
14 | import * as schemas from "../types/schemas.js";
15 | import * as args from "../types/args.js";
16 |
17 | /**
18 | * Start the MCP server
19 | */
20 | export async function startServer(
21 | notionToken: string,
22 | enabledToolsSet: Set<string>,
23 | enableMarkdownConversion: boolean
24 | ) {
25 | const server = new Server(
26 | {
27 | name: "Notion MCP Server",
28 | version: "1.0.0",
29 | },
30 | {
31 | capabilities: {
32 | tools: {},
33 | },
34 | }
35 | );
36 |
37 | const notionClient = new NotionClientWrapper(notionToken);
38 |
39 | server.setRequestHandler(
40 | CallToolRequestSchema,
41 | async (request: CallToolRequest) => {
42 | console.error("Received CallToolRequest:", request);
43 | try {
44 | if (!request.params.arguments) {
45 | throw new Error("No arguments provided");
46 | }
47 |
48 | let response;
49 |
50 | switch (request.params.name) {
51 | case "notion_append_block_children": {
52 | const args = request.params
53 | .arguments as unknown as args.AppendBlockChildrenArgs;
54 | if (!args.block_id || !args.children) {
55 | throw new Error(
56 | "Missing required arguments: block_id and children"
57 | );
58 | }
59 | response = await notionClient.appendBlockChildren(
60 | args.block_id,
61 | args.children
62 | );
63 | break;
64 | }
65 |
66 | case "notion_retrieve_block": {
67 | const args = request.params
68 | .arguments as unknown as args.RetrieveBlockArgs;
69 | if (!args.block_id) {
70 | throw new Error("Missing required argument: block_id");
71 | }
72 | response = await notionClient.retrieveBlock(args.block_id);
73 | break;
74 | }
75 |
76 | case "notion_retrieve_block_children": {
77 | const args = request.params
78 | .arguments as unknown as args.RetrieveBlockChildrenArgs;
79 | if (!args.block_id) {
80 | throw new Error("Missing required argument: block_id");
81 | }
82 | response = await notionClient.retrieveBlockChildren(
83 | args.block_id,
84 | args.start_cursor,
85 | args.page_size
86 | );
87 | break;
88 | }
89 |
90 | case "notion_delete_block": {
91 | const args = request.params
92 | .arguments as unknown as args.DeleteBlockArgs;
93 | if (!args.block_id) {
94 | throw new Error("Missing required argument: block_id");
95 | }
96 | response = await notionClient.deleteBlock(args.block_id);
97 | break;
98 | }
99 |
100 | case "notion_update_block": {
101 | const args = request.params
102 | .arguments as unknown as args.UpdateBlockArgs;
103 | if (!args.block_id || !args.block) {
104 | throw new Error("Missing required arguments: block_id and block");
105 | }
106 | response = await notionClient.updateBlock(
107 | args.block_id,
108 | args.block
109 | );
110 | break;
111 | }
112 |
113 | case "notion_retrieve_page": {
114 | const args = request.params
115 | .arguments as unknown as args.RetrievePageArgs;
116 | if (!args.page_id) {
117 | throw new Error("Missing required argument: page_id");
118 | }
119 | response = await notionClient.retrievePage(args.page_id);
120 | break;
121 | }
122 |
123 | case "notion_update_page_properties": {
124 | const args = request.params
125 | .arguments as unknown as args.UpdatePagePropertiesArgs;
126 | if (!args.page_id || !args.properties) {
127 | throw new Error(
128 | "Missing required arguments: page_id and properties"
129 | );
130 | }
131 | response = await notionClient.updatePageProperties(
132 | args.page_id,
133 | args.properties
134 | );
135 | break;
136 | }
137 |
138 | case "notion_list_all_users": {
139 | const args = request.params
140 | .arguments as unknown as args.ListAllUsersArgs;
141 | response = await notionClient.listAllUsers(
142 | args.start_cursor,
143 | args.page_size
144 | );
145 | break;
146 | }
147 |
148 | case "notion_retrieve_user": {
149 | const args = request.params
150 | .arguments as unknown as args.RetrieveUserArgs;
151 | if (!args.user_id) {
152 | throw new Error("Missing required argument: user_id");
153 | }
154 | response = await notionClient.retrieveUser(args.user_id);
155 | break;
156 | }
157 |
158 | case "notion_retrieve_bot_user": {
159 | response = await notionClient.retrieveBotUser();
160 | break;
161 | }
162 |
163 | case "notion_query_database": {
164 | const args = request.params
165 | .arguments as unknown as args.QueryDatabaseArgs;
166 | if (!args.database_id) {
167 | throw new Error("Missing required argument: database_id");
168 | }
169 | response = await notionClient.queryDatabase(
170 | args.database_id,
171 | args.filter,
172 | args.sorts,
173 | args.start_cursor,
174 | args.page_size
175 | );
176 | break;
177 | }
178 |
179 | case "notion_create_database": {
180 | const args = request.params
181 | .arguments as unknown as args.CreateDatabaseArgs;
182 | response = await notionClient.createDatabase(
183 | args.parent,
184 | args.properties,
185 | args.title
186 | );
187 | break;
188 | }
189 |
190 | case "notion_retrieve_database": {
191 | const args = request.params
192 | .arguments as unknown as args.RetrieveDatabaseArgs;
193 | response = await notionClient.retrieveDatabase(args.database_id);
194 | break;
195 | }
196 |
197 | case "notion_update_database": {
198 | const args = request.params
199 | .arguments as unknown as args.UpdateDatabaseArgs;
200 | response = await notionClient.updateDatabase(
201 | args.database_id,
202 | args.title,
203 | args.description,
204 | args.properties
205 | );
206 | break;
207 | }
208 |
209 | case "notion_create_database_item": {
210 | const args = request.params
211 | .arguments as unknown as args.CreateDatabaseItemArgs;
212 | response = await notionClient.createDatabaseItem(
213 | args.database_id,
214 | args.properties
215 | );
216 | break;
217 | }
218 |
219 | case "notion_create_comment": {
220 | const args = request.params
221 | .arguments as unknown as args.CreateCommentArgs;
222 |
223 | if (!args.parent && !args.discussion_id) {
224 | throw new Error(
225 | "Either parent.page_id or discussion_id must be provided"
226 | );
227 | }
228 |
229 | response = await notionClient.createComment(
230 | args.parent,
231 | args.discussion_id,
232 | args.rich_text
233 | );
234 | break;
235 | }
236 |
237 | case "notion_retrieve_comments": {
238 | const args = request.params
239 | .arguments as unknown as args.RetrieveCommentsArgs;
240 | if (!args.block_id) {
241 | throw new Error("Missing required argument: block_id");
242 | }
243 | response = await notionClient.retrieveComments(
244 | args.block_id,
245 | args.start_cursor,
246 | args.page_size
247 | );
248 | break;
249 | }
250 |
251 | case "notion_search": {
252 | const args = request.params.arguments as unknown as args.SearchArgs;
253 | response = await notionClient.search(
254 | args.query,
255 | args.filter,
256 | args.sort,
257 | args.start_cursor,
258 | args.page_size
259 | );
260 | break;
261 | }
262 |
263 | default:
264 | throw new Error(`Unknown tool: ${request.params.name}`);
265 | }
266 |
267 | // Check format parameter and return appropriate response
268 | const requestedFormat =
269 | (request.params.arguments as any)?.format || "markdown";
270 |
271 | // Only convert to markdown if both conditions are met:
272 | // 1. The requested format is markdown
273 | // 2. The experimental markdown conversion is enabled via environment variable
274 | if (enableMarkdownConversion && requestedFormat === "markdown") {
275 | const markdown = await notionClient.toMarkdown(response);
276 | return {
277 | content: [{ type: "text", text: markdown }],
278 | };
279 | } else {
280 | return {
281 | content: [
282 | { type: "text", text: JSON.stringify(response, null, 2) },
283 | ],
284 | };
285 | }
286 | } catch (error) {
287 | console.error("Error executing tool:", error);
288 | return {
289 | content: [
290 | {
291 | type: "text",
292 | text: JSON.stringify({
293 | error: error instanceof Error ? error.message : String(error),
294 | }),
295 | },
296 | ],
297 | };
298 | }
299 | }
300 | );
301 |
302 | server.setRequestHandler(ListToolsRequestSchema, async () => {
303 | const allTools = [
304 | schemas.appendBlockChildrenTool,
305 | schemas.retrieveBlockTool,
306 | schemas.retrieveBlockChildrenTool,
307 | schemas.deleteBlockTool,
308 | schemas.updateBlockTool,
309 | schemas.retrievePageTool,
310 | schemas.updatePagePropertiesTool,
311 | schemas.listAllUsersTool,
312 | schemas.retrieveUserTool,
313 | schemas.retrieveBotUserTool,
314 | schemas.createDatabaseTool,
315 | schemas.queryDatabaseTool,
316 | schemas.retrieveDatabaseTool,
317 | schemas.updateDatabaseTool,
318 | schemas.createDatabaseItemTool,
319 | schemas.createCommentTool,
320 | schemas.retrieveCommentsTool,
321 | schemas.searchTool,
322 | ];
323 | return {
324 | tools: filterTools(allTools, enabledToolsSet),
325 | };
326 | });
327 |
328 | const transport = new StdioServerTransport();
329 | await server.connect(transport);
330 | }
331 |
```
--------------------------------------------------------------------------------
/src/types/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Schema definitions for Notion API tools
3 | */
4 |
5 | import { Tool } from "@modelcontextprotocol/sdk/types.js";
6 | import {
7 | commonIdDescription,
8 | formatParameter,
9 | richTextObjectSchema,
10 | blockObjectSchema,
11 | } from "./common.js";
12 |
13 | // Blocks tools
14 | export const appendBlockChildrenTool: Tool = {
15 | name: "notion_append_block_children",
16 | description:
17 | "Append new children blocks to a specified parent block in Notion. Requires insert content capabilities. You can optionally specify the 'after' parameter to append after a certain block.",
18 | inputSchema: {
19 | type: "object",
20 | properties: {
21 | block_id: {
22 | type: "string",
23 | description: "The ID of the parent block." + commonIdDescription,
24 | },
25 | children: {
26 | type: "array",
27 | description:
28 | "Array of block objects to append. Each block must follow the Notion block schema.",
29 | items: blockObjectSchema,
30 | },
31 | after: {
32 | type: "string",
33 | description:
34 | "The ID of the existing block that the new block should be appended after." +
35 | commonIdDescription,
36 | },
37 | format: formatParameter,
38 | },
39 | required: ["block_id", "children"],
40 | },
41 | };
42 |
43 | export const retrieveBlockTool: Tool = {
44 | name: "notion_retrieve_block",
45 | description: "Retrieve a block from Notion",
46 | inputSchema: {
47 | type: "object",
48 | properties: {
49 | block_id: {
50 | type: "string",
51 | description: "The ID of the block to retrieve." + commonIdDescription,
52 | },
53 | format: formatParameter,
54 | },
55 | required: ["block_id"],
56 | },
57 | };
58 |
59 | export const retrieveBlockChildrenTool: Tool = {
60 | name: "notion_retrieve_block_children",
61 | description: "Retrieve the children of a block",
62 | inputSchema: {
63 | type: "object",
64 | properties: {
65 | block_id: {
66 | type: "string",
67 | description: "The ID of the block." + commonIdDescription,
68 | },
69 | start_cursor: {
70 | type: "string",
71 | description: "Pagination cursor for next page of results",
72 | },
73 | page_size: {
74 | type: "number",
75 | description: "Number of results per page (max 100)",
76 | },
77 | format: formatParameter,
78 | },
79 | required: ["block_id"],
80 | },
81 | };
82 |
83 | export const deleteBlockTool: Tool = {
84 | name: "notion_delete_block",
85 | description: "Delete a block in Notion",
86 | inputSchema: {
87 | type: "object",
88 | properties: {
89 | block_id: {
90 | type: "string",
91 | description: "The ID of the block to delete." + commonIdDescription,
92 | },
93 | format: formatParameter,
94 | },
95 | required: ["block_id"],
96 | },
97 | };
98 |
99 | export const updateBlockTool: Tool = {
100 | name: "notion_update_block",
101 | description:
102 | "Update the content of a block in Notion based on its type. The update replaces the entire value for a given field.",
103 | inputSchema: {
104 | type: "object",
105 | properties: {
106 | block_id: {
107 | type: "string",
108 | description: "The ID of the block to update." + commonIdDescription,
109 | },
110 | block: {
111 | type: "object",
112 | description:
113 | "The updated content for the block. Must match the block's type schema.",
114 | },
115 | format: formatParameter,
116 | },
117 | required: ["block_id", "block"],
118 | },
119 | };
120 |
121 | // Pages tools
122 | export const retrievePageTool: Tool = {
123 | name: "notion_retrieve_page",
124 | description: "Retrieve a page from Notion",
125 | inputSchema: {
126 | type: "object",
127 | properties: {
128 | page_id: {
129 | type: "string",
130 | description: "The ID of the page to retrieve." + commonIdDescription,
131 | },
132 | format: formatParameter,
133 | },
134 | required: ["page_id"],
135 | },
136 | };
137 |
138 | export const updatePagePropertiesTool: Tool = {
139 | name: "notion_update_page_properties",
140 | description: "Update properties of a page or an item in a Notion database",
141 | inputSchema: {
142 | type: "object",
143 | properties: {
144 | page_id: {
145 | type: "string",
146 | description:
147 | "The ID of the page or database item to update." +
148 | commonIdDescription,
149 | },
150 | properties: {
151 | type: "object",
152 | description:
153 | "Properties to update. These correspond to the columns or fields in the database.",
154 | },
155 | format: formatParameter,
156 | },
157 | required: ["page_id", "properties"],
158 | },
159 | };
160 |
161 | // Users tools
162 | export const listAllUsersTool: Tool = {
163 | name: "notion_list_all_users",
164 | description:
165 | "List all users in the Notion workspace. **Note:** This function requires upgrading to the Notion Enterprise plan and using an Organization API key to avoid permission errors.",
166 | inputSchema: {
167 | type: "object",
168 | properties: {
169 | start_cursor: {
170 | type: "string",
171 | description: "Pagination start cursor for listing users",
172 | },
173 | page_size: {
174 | type: "number",
175 | description: "Number of users to retrieve (max 100)",
176 | },
177 | format: formatParameter,
178 | },
179 | },
180 | };
181 |
182 | export const retrieveUserTool: Tool = {
183 | name: "notion_retrieve_user",
184 | description:
185 | "Retrieve a specific user by user_id in Notion. **Note:** This function requires upgrading to the Notion Enterprise plan and using an Organization API key to avoid permission errors.",
186 | inputSchema: {
187 | type: "object",
188 | properties: {
189 | user_id: {
190 | type: "string",
191 | description: "The ID of the user to retrieve." + commonIdDescription,
192 | },
193 | format: formatParameter,
194 | },
195 | required: ["user_id"],
196 | },
197 | };
198 |
199 | export const retrieveBotUserTool: Tool = {
200 | name: "notion_retrieve_bot_user",
201 | description:
202 | "Retrieve the bot user associated with the current token in Notion",
203 | inputSchema: {
204 | type: "object",
205 | properties: {
206 | random_string: {
207 | type: "string",
208 | description: "Dummy parameter for no-parameter tools",
209 | },
210 | format: formatParameter,
211 | },
212 | required: ["random_string"],
213 | },
214 | };
215 |
216 | // Databases tools
217 | export const createDatabaseTool: Tool = {
218 | name: "notion_create_database",
219 | description: "Create a database in Notion",
220 | inputSchema: {
221 | type: "object",
222 | properties: {
223 | parent: {
224 | type: "object",
225 | description: "Parent object of the database",
226 | },
227 | title: {
228 | type: "array",
229 | description:
230 | "Title of database as it appears in Notion. An array of rich text objects.",
231 | items: richTextObjectSchema,
232 | },
233 | properties: {
234 | type: "object",
235 | description:
236 | "Property schema of database. The keys are the names of properties as they appear in Notion and the values are property schema objects.",
237 | },
238 | format: formatParameter,
239 | },
240 | required: ["parent", "properties"],
241 | },
242 | };
243 |
244 | export const queryDatabaseTool: Tool = {
245 | name: "notion_query_database",
246 | description: "Query a database in Notion",
247 | inputSchema: {
248 | type: "object",
249 | properties: {
250 | database_id: {
251 | type: "string",
252 | description: "The ID of the database to query." + commonIdDescription,
253 | },
254 | filter: {
255 | type: "object",
256 | description: "Filter conditions",
257 | },
258 | sorts: {
259 | type: "array",
260 | description: "Sort conditions",
261 | items: {
262 | type: "object",
263 | properties: {
264 | property: { type: "string" },
265 | timestamp: { type: "string" },
266 | direction: {
267 | type: "string",
268 | enum: ["ascending", "descending"],
269 | },
270 | },
271 | required: ["direction"],
272 | },
273 | },
274 | start_cursor: {
275 | type: "string",
276 | description: "Pagination cursor for next page of results",
277 | },
278 | page_size: {
279 | type: "number",
280 | description: "Number of results per page (max 100)",
281 | },
282 | format: formatParameter,
283 | },
284 | required: ["database_id"],
285 | },
286 | };
287 |
288 | export const retrieveDatabaseTool: Tool = {
289 | name: "notion_retrieve_database",
290 | description: "Retrieve a database in Notion",
291 | inputSchema: {
292 | type: "object",
293 | properties: {
294 | database_id: {
295 | type: "string",
296 | description:
297 | "The ID of the database to retrieve." + commonIdDescription,
298 | },
299 | format: formatParameter,
300 | },
301 | required: ["database_id"],
302 | },
303 | };
304 |
305 | export const updateDatabaseTool: Tool = {
306 | name: "notion_update_database",
307 | description: "Update a database in Notion",
308 | inputSchema: {
309 | type: "object",
310 | properties: {
311 | database_id: {
312 | type: "string",
313 | description: "The ID of the database to update." + commonIdDescription,
314 | },
315 | title: {
316 | type: "array",
317 | description:
318 | "An array of rich text objects that represents the title of the database that is displayed in the Notion UI.",
319 | items: richTextObjectSchema,
320 | },
321 | description: {
322 | type: "array",
323 | description:
324 | "An array of rich text objects that represents the description of the database that is displayed in the Notion UI.",
325 | items: richTextObjectSchema,
326 | },
327 | properties: {
328 | type: "object",
329 | description:
330 | "The properties of a database to be changed in the request, in the form of a JSON object.",
331 | },
332 | format: formatParameter,
333 | },
334 | required: ["database_id"],
335 | },
336 | };
337 |
338 | export const createDatabaseItemTool: Tool = {
339 | name: "notion_create_database_item",
340 | description: "Create a new item (page) in a Notion database",
341 | inputSchema: {
342 | type: "object",
343 | properties: {
344 | database_id: {
345 | type: "string",
346 | description:
347 | "The ID of the database to add the item to." + commonIdDescription,
348 | },
349 | properties: {
350 | type: "object",
351 | description:
352 | "Properties of the new database item. These should match the database schema.",
353 | },
354 | format: formatParameter,
355 | },
356 | required: ["database_id", "properties"],
357 | },
358 | };
359 |
360 | // Comments tools
361 | export const createCommentTool: Tool = {
362 | name: "notion_create_comment",
363 | description:
364 | "Create a comment in Notion. This requires the integration to have 'insert comment' capabilities. You can either specify a page parent or a discussion_id, but not both.",
365 | inputSchema: {
366 | type: "object",
367 | properties: {
368 | parent: {
369 | type: "object",
370 | description:
371 | "Parent object that specifies the page to comment on. Must include a page_id if used.",
372 | properties: {
373 | page_id: {
374 | type: "string",
375 | description:
376 | "The ID of the page to comment on." + commonIdDescription,
377 | },
378 | },
379 | },
380 | discussion_id: {
381 | type: "string",
382 | description:
383 | "The ID of an existing discussion thread to add a comment to." +
384 | commonIdDescription,
385 | },
386 | rich_text: {
387 | type: "array",
388 | description:
389 | "Array of rich text objects representing the comment content.",
390 | items: richTextObjectSchema,
391 | },
392 | format: formatParameter,
393 | },
394 | required: ["rich_text"],
395 | },
396 | };
397 |
398 | export const retrieveCommentsTool: Tool = {
399 | name: "notion_retrieve_comments",
400 | description:
401 | "Retrieve a list of unresolved comments from a Notion page or block. Requires the integration to have 'read comment' capabilities.",
402 | inputSchema: {
403 | type: "object",
404 | properties: {
405 | block_id: {
406 | type: "string",
407 | description:
408 | "The ID of the block or page whose comments you want to retrieve." +
409 | commonIdDescription,
410 | },
411 | start_cursor: {
412 | type: "string",
413 | description:
414 | "If supplied, returns a page of results starting after the cursor.",
415 | },
416 | page_size: {
417 | type: "number",
418 | description: "Number of comments to retrieve (max 100).",
419 | },
420 | format: formatParameter,
421 | },
422 | required: ["block_id"],
423 | },
424 | };
425 |
426 | // Search tool
427 | export const searchTool: Tool = {
428 | name: "notion_search",
429 | description: "Search pages or databases by title in Notion",
430 | inputSchema: {
431 | type: "object",
432 | properties: {
433 | query: {
434 | type: "string",
435 | description: "Text to search for in page or database titles",
436 | },
437 | filter: {
438 | type: "object",
439 | description: "Filter results by object type (page or database)",
440 | properties: {
441 | property: {
442 | type: "string",
443 | description: "Must be 'object'",
444 | },
445 | value: {
446 | type: "string",
447 | description: "Either 'page' or 'database'",
448 | },
449 | },
450 | },
451 | sort: {
452 | type: "object",
453 | description: "Sort order of results",
454 | properties: {
455 | direction: {
456 | type: "string",
457 | enum: ["ascending", "descending"],
458 | },
459 | timestamp: {
460 | type: "string",
461 | enum: ["last_edited_time"],
462 | },
463 | },
464 | },
465 | start_cursor: {
466 | type: "string",
467 | description: "Pagination start cursor",
468 | },
469 | page_size: {
470 | type: "number",
471 | description: "Number of results to return (max 100). ",
472 | },
473 | format: formatParameter,
474 | },
475 | },
476 | };
477 |
```
--------------------------------------------------------------------------------
/src/types/common.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Common schema definitions for Notion tools
3 | */
4 |
5 | // Common ID description
6 | export const commonIdDescription =
7 | "It should be a 32-character string (excluding hyphens) formatted as 8-4-4-4-12 with hyphens (-).";
8 |
9 | // Format parameter schema
10 | export const formatParameter = {
11 | type: "string",
12 | enum: ["json", "markdown"],
13 | description:
14 | "Specify the response format. 'json' returns the original data structure, 'markdown' returns a more readable format. Use 'markdown' when the user only needs to read the page and isn't planning to write or modify it. Use 'json' when the user needs to read the page with the intention of writing to or modifying it.",
15 | default: "markdown",
16 | };
17 |
18 | // Rich text object schema
19 | export const richTextObjectSchema = {
20 | type: "object",
21 | description: "A rich text object.",
22 | properties: {
23 | type: {
24 | type: "string",
25 | description:
26 | "The type of this rich text object. Possible values: text, mention, equation.",
27 | enum: ["text", "mention", "equation"],
28 | },
29 | text: {
30 | type: "object",
31 | description:
32 | "Object containing text content and optional link info. Required if type is 'text'.",
33 | properties: {
34 | content: {
35 | type: "string",
36 | description: "The actual text content.",
37 | },
38 | link: {
39 | type: "object",
40 | description: "Optional link object with a 'url' field. Do NOT provide a NULL value, just ignore this field no link.",
41 | properties: {
42 | url: {
43 | type: "string",
44 | description: "The URL the text links to.",
45 | },
46 | },
47 | },
48 | },
49 | },
50 | mention: {
51 | type: "object",
52 | description:
53 | "Mention object if type is 'mention'. Represents an inline mention of a database, date, link preview, page, template mention, or user.",
54 | properties: {
55 | type: {
56 | type: "string",
57 | description: "The type of the mention.",
58 | enum: [
59 | "database",
60 | "date",
61 | "link_preview",
62 | "page",
63 | "template_mention",
64 | "user",
65 | ],
66 | },
67 | database: {
68 | type: "object",
69 | description:
70 | "Database mention object. Contains a database reference with an 'id' field.",
71 | properties: {
72 | id: {
73 | type: "string",
74 | description:
75 | "The ID of the mentioned database." + commonIdDescription,
76 | },
77 | },
78 | required: ["id"],
79 | },
80 | date: {
81 | type: "object",
82 | description:
83 | "Date mention object, containing a date property value object.",
84 | properties: {
85 | start: {
86 | type: "string",
87 | description: "An ISO 8601 formatted start date or date-time.",
88 | },
89 | end: {
90 | type: ["string", "null"],
91 | description:
92 | "An ISO 8601 formatted end date or date-time, or null if not a range.",
93 | },
94 | time_zone: {
95 | type: ["string", "null"],
96 | description:
97 | "Time zone information for start and end. If null, times are in UTC.",
98 | },
99 | },
100 | required: ["start"],
101 | },
102 | link_preview: {
103 | type: "object",
104 | description:
105 | "Link Preview mention object, containing a URL for the link preview.",
106 | properties: {
107 | url: {
108 | type: "string",
109 | description: "The URL for the link preview.",
110 | },
111 | },
112 | required: ["url"],
113 | },
114 | page: {
115 | type: "object",
116 | description:
117 | "Page mention object, containing a page reference with an 'id' field.",
118 | properties: {
119 | id: {
120 | type: "string",
121 | description:
122 | "The ID of the mentioned page." + commonIdDescription,
123 | },
124 | },
125 | required: ["id"],
126 | },
127 | template_mention: {
128 | type: "object",
129 | description:
130 | "Template mention object, can be a template_mention_date or template_mention_user.",
131 | properties: {
132 | type: {
133 | type: "string",
134 | enum: ["template_mention_date", "template_mention_user"],
135 | description: "The template mention type.",
136 | },
137 | template_mention_date: {
138 | type: "string",
139 | enum: ["today", "now"],
140 | description: "For template_mention_date type, the date keyword.",
141 | },
142 | template_mention_user: {
143 | type: "string",
144 | enum: ["me"],
145 | description: "For template_mention_user type, the user keyword.",
146 | },
147 | },
148 | },
149 | user: {
150 | type: "object",
151 | description: "User mention object, contains a user reference.",
152 | properties: {
153 | object: {
154 | type: "string",
155 | description: "Should be 'user'.",
156 | enum: ["user"],
157 | },
158 | id: {
159 | type: "string",
160 | description: "The ID of the user." + commonIdDescription,
161 | },
162 | },
163 | required: ["object", "id"],
164 | },
165 | },
166 | required: ["type"],
167 | oneOf: [
168 | { required: ["database"] },
169 | { required: ["date"] },
170 | { required: ["link_preview"] },
171 | { required: ["page"] },
172 | { required: ["template_mention"] },
173 | { required: ["user"] },
174 | ],
175 | },
176 | equation: {
177 | type: "object",
178 | description:
179 | "Equation object if type is 'equation'. Represents an inline LaTeX equation.",
180 | properties: {
181 | expression: {
182 | type: "string",
183 | description: "LaTeX string representing the inline equation.",
184 | },
185 | },
186 | required: ["expression"],
187 | },
188 | annotations: {
189 | type: "object",
190 | description: "Styling information for the text. By default, give nothing for default text.",
191 | properties: {
192 | bold: { type: "boolean" },
193 | italic: { type: "boolean" },
194 | strikethrough: { type: "boolean" },
195 | underline: { type: "boolean" },
196 | code: { type: "boolean" },
197 | color: {
198 | type: "string",
199 | description: "Color for the text.",
200 | enum: [
201 | "default",
202 | "blue",
203 | "blue_background",
204 | "brown",
205 | "brown_background",
206 | "gray",
207 | "gray_background",
208 | "green",
209 | "green_background",
210 | "orange",
211 | "orange_background",
212 | "pink",
213 | "pink_background",
214 | "purple",
215 | "purple_background",
216 | "red",
217 | "red_background",
218 | "yellow",
219 | "yellow_background",
220 | ],
221 | },
222 | },
223 | },
224 | href: {
225 | type: "string",
226 | description: "The URL of any link or mention in this text, if any. Do NOT provide a NULL value, just ignore this field if there is no link or mention.",
227 | },
228 | plain_text: {
229 | type: "string",
230 | description: "The plain text without annotations.",
231 | },
232 | },
233 | required: ["type"],
234 | };
235 |
236 | // Block object schema
237 | export const blockObjectSchema = {
238 | type: "object",
239 | description: "A Notion block object.",
240 | properties: {
241 | object: {
242 | type: "string",
243 | description: "Should be 'block'.",
244 | enum: ["block"],
245 | },
246 | type: {
247 | type: "string",
248 | description:
249 | "Type of the block. Possible values include 'paragraph', 'heading_1', 'heading_2', 'heading_3', 'bulleted_list_item', 'numbered_list_item', 'to_do', 'toggle', 'child_page', 'child_database', 'embed', 'callout', 'quote', 'equation', 'divider', 'table_of_contents', 'column', 'column_list', 'link_preview', 'synced_block', 'template', 'link_to_page', 'audio', 'bookmark', 'breadcrumb', 'code', 'file', 'image', 'pdf', 'video'. Not all types are supported for creation via API.",
250 | },
251 | paragraph: {
252 | type: "object",
253 | description: "Paragraph block object.",
254 | properties: {
255 | rich_text: {
256 | type: "array",
257 | description:
258 | "Array of rich text objects representing the comment content.",
259 | items: richTextObjectSchema,
260 | },
261 | color: {
262 | type: "string",
263 | description: "The color of the block.",
264 | enum: [
265 | "default",
266 | "blue",
267 | "blue_background",
268 | "brown",
269 | "brown_background",
270 | "gray",
271 | "gray_background",
272 | "green",
273 | "green_background",
274 | "orange",
275 | "orange_background",
276 | "pink",
277 | "pink_background",
278 | "purple",
279 | "purple_background",
280 | "red",
281 | "red_background",
282 | "yellow",
283 | "yellow_background",
284 | ],
285 | },
286 | children: {
287 | type: "array",
288 | description: "Nested child blocks.",
289 | items: {
290 | type: "object",
291 | description: "A nested block object.",
292 | },
293 | },
294 | },
295 | },
296 | heading_1: {
297 | type: "object",
298 | description: "Heading 1 block object.",
299 | properties: {
300 | rich_text: {
301 | type: "array",
302 | description: "Array of rich text objects representing the heading content.",
303 | items: richTextObjectSchema,
304 | },
305 | color: {
306 | type: "string",
307 | description: "The color of the block.",
308 | enum: [
309 | "default",
310 | "blue",
311 | "blue_background",
312 | "brown",
313 | "brown_background",
314 | "gray",
315 | "gray_background",
316 | "green",
317 | "green_background",
318 | "orange",
319 | "orange_background",
320 | "pink",
321 | "pink_background",
322 | "purple",
323 | "purple_background",
324 | "red",
325 | "red_background",
326 | "yellow",
327 | "yellow_background",
328 | ],
329 | },
330 | is_toggleable: {
331 | type: "boolean",
332 | description: "Whether the heading can be toggled.",
333 | },
334 | },
335 | },
336 | heading_2: {
337 | type: "object",
338 | description: "Heading 2 block object.",
339 | properties: {
340 | rich_text: {
341 | type: "array",
342 | description: "Array of rich text objects representing the heading content.",
343 | items: richTextObjectSchema,
344 | },
345 | color: {
346 | type: "string",
347 | description: "The color of the block.",
348 | enum: [
349 | "default",
350 | "blue",
351 | "blue_background",
352 | "brown",
353 | "brown_background",
354 | "gray",
355 | "gray_background",
356 | "green",
357 | "green_background",
358 | "orange",
359 | "orange_background",
360 | "pink",
361 | "pink_background",
362 | "purple",
363 | "purple_background",
364 | "red",
365 | "red_background",
366 | "yellow",
367 | "yellow_background",
368 | ],
369 | },
370 | is_toggleable: {
371 | type: "boolean",
372 | description: "Whether the heading can be toggled.",
373 | },
374 | },
375 | },
376 | heading_3: {
377 | type: "object",
378 | description: "Heading 3 block object.",
379 | properties: {
380 | rich_text: {
381 | type: "array",
382 | description: "Array of rich text objects representing the heading content.",
383 | items: richTextObjectSchema,
384 | },
385 | color: {
386 | type: "string",
387 | description: "The color of the block.",
388 | enum: [
389 | "default",
390 | "blue",
391 | "blue_background",
392 | "brown",
393 | "brown_background",
394 | "gray",
395 | "gray_background",
396 | "green",
397 | "green_background",
398 | "orange",
399 | "orange_background",
400 | "pink",
401 | "pink_background",
402 | "purple",
403 | "purple_background",
404 | "red",
405 | "red_background",
406 | "yellow",
407 | "yellow_background",
408 | ],
409 | },
410 | is_toggleable: {
411 | type: "boolean",
412 | description: "Whether the heading can be toggled.",
413 | },
414 | },
415 | },
416 | bulleted_list_item: {
417 | type: "object",
418 | description: "Bulleted list item block object.",
419 | properties: {
420 | rich_text: {
421 | type: "array",
422 | description: "Array of rich text objects representing the list item content.",
423 | items: richTextObjectSchema,
424 | },
425 | color: {
426 | type: "string",
427 | description: "The color of the block.",
428 | enum: [
429 | "default",
430 | "blue",
431 | "blue_background",
432 | "brown",
433 | "brown_background",
434 | "gray",
435 | "gray_background",
436 | "green",
437 | "green_background",
438 | "orange",
439 | "orange_background",
440 | "pink",
441 | "pink_background",
442 | "purple",
443 | "purple_background",
444 | "red",
445 | "red_background",
446 | "yellow",
447 | "yellow_background",
448 | ],
449 | },
450 | children: {
451 | type: "array",
452 | description: "Nested child blocks.",
453 | items: {
454 | type: "object",
455 | description: "A nested block object.",
456 | },
457 | },
458 | },
459 | },
460 | numbered_list_item: {
461 | type: "object",
462 | description: "Numbered list item block object.",
463 | properties: {
464 | rich_text: {
465 | type: "array",
466 | description: "Array of rich text objects representing the list item content.",
467 | items: richTextObjectSchema,
468 | },
469 | color: {
470 | type: "string",
471 | description: "The color of the block.",
472 | enum: [
473 | "default",
474 | "blue",
475 | "blue_background",
476 | "brown",
477 | "brown_background",
478 | "gray",
479 | "gray_background",
480 | "green",
481 | "green_background",
482 | "orange",
483 | "orange_background",
484 | "pink",
485 | "pink_background",
486 | "purple",
487 | "purple_background",
488 | "red",
489 | "red_background",
490 | "yellow",
491 | "yellow_background",
492 | ],
493 | },
494 | children: {
495 | type: "array",
496 | description: "Nested child blocks.",
497 | items: {
498 | type: "object",
499 | description: "A nested block object.",
500 | },
501 | },
502 | },
503 | },
504 | toggle: {
505 | type: "object",
506 | description: "Toggle block object.",
507 | properties: {
508 | rich_text: {
509 | type: "array",
510 | description: "Array of rich text objects representing the toggle content.",
511 | items: richTextObjectSchema,
512 | },
513 | color: {
514 | type: "string",
515 | description: "The color of the block.",
516 | enum: [
517 | "default",
518 | "blue",
519 | "blue_background",
520 | "brown",
521 | "brown_background",
522 | "gray",
523 | "gray_background",
524 | "green",
525 | "green_background",
526 | "orange",
527 | "orange_background",
528 | "pink",
529 | "pink_background",
530 | "purple",
531 | "purple_background",
532 | "red",
533 | "red_background",
534 | "yellow",
535 | "yellow_background",
536 | ],
537 | },
538 | children: {
539 | type: "array",
540 | description: "Nested child blocks that are revealed when the toggle is opened.",
541 | items: {
542 | type: "object",
543 | description: "A nested block object.",
544 | },
545 | },
546 | },
547 | },
548 | divider: {
549 | type: "object",
550 | description: "Divider block object.",
551 | properties: {},
552 | },
553 | },
554 | required: ["object", "type"],
555 | };
556 |
```
--------------------------------------------------------------------------------
/src/markdown/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Utilities for converting Notion API responses to Markdown
3 | */
4 | import {
5 | NotionResponse,
6 | PageResponse,
7 | DatabaseResponse,
8 | BlockResponse,
9 | ListResponse,
10 | RichTextItemResponse,
11 | PageProperty,
12 | } from "../types/index.js";
13 |
14 | /**
15 | * Converts Notion API response to Markdown
16 | * @param response Response from Notion API
17 | * @returns Markdown formatted string
18 | */
19 | export function convertToMarkdown(response: NotionResponse): string {
20 | // Execute appropriate conversion process based on response type
21 | if (!response) return "";
22 |
23 | // Branch processing by object type
24 | switch (response.object) {
25 | case "page":
26 | return convertPageToMarkdown(response as PageResponse);
27 | case "database":
28 | return convertDatabaseToMarkdown(response as DatabaseResponse);
29 | case "block":
30 | return convertBlockToMarkdown(response as BlockResponse);
31 | case "list":
32 | return convertListToMarkdown(response as ListResponse);
33 | default:
34 | // Return JSON string if conversion is not possible
35 | return `\`\`\`json\n${JSON.stringify(response, null, 2)}\n\`\`\``;
36 | }
37 | }
38 |
39 | /**
40 | * Converts a Notion page to Markdown
41 | */
42 | function convertPageToMarkdown(page: PageResponse): string {
43 | let markdown = "";
44 |
45 | // Extract title (from properties)
46 | const title = extractPageTitle(page);
47 | if (title) {
48 | markdown += `# ${title}\n\n`;
49 | }
50 |
51 | // Display page properties as a Markdown table
52 | markdown += convertPropertiesToMarkdown(page.properties);
53 |
54 | // Include additional information if there are child blocks
55 | markdown +=
56 | "\n\n> This page contains child blocks. You can retrieve them using `retrieveBlockChildren`.\n";
57 | markdown += `> Block ID: \`${page.id}\`\n`;
58 |
59 | // Add link to view the page in Notion
60 | if (page.url) {
61 | markdown += `\n[View in Notion](${page.url})\n`;
62 | }
63 |
64 | return markdown;
65 | }
66 |
67 | /**
68 | * Converts a Notion database to Markdown
69 | */
70 | function convertDatabaseToMarkdown(database: DatabaseResponse): string {
71 | let markdown = "";
72 |
73 | // Extract database title
74 | const title = extractRichText(database.title || []);
75 | if (title) {
76 | markdown += `# ${title} (Database)\n\n`;
77 | }
78 |
79 | // Add description if available
80 | const description = extractRichText(database.description || []);
81 | if (description) {
82 | markdown += `${description}\n\n`;
83 | }
84 |
85 | // Display database property schema
86 | if (database.properties) {
87 | markdown += "## Properties\n\n";
88 | markdown += "| Property Name | Type | Details |\n";
89 | markdown += "|------------|------|------|\n";
90 |
91 | Object.entries(database.properties).forEach(([key, prop]) => {
92 | const propName = prop.name || key;
93 | const propType = prop.type || "unknown";
94 |
95 | // Additional information based on property type
96 | let details = "";
97 | switch (propType) {
98 | case "select":
99 | case "multi_select":
100 | const options = prop[propType]?.options || [];
101 | details = `Options: ${options.map((o: any) => o.name).join(", ")}`;
102 | break;
103 | case "relation":
104 | details = `Related DB: ${prop.relation?.database_id || ""}`;
105 | break;
106 | case "formula":
107 | details = `Formula: ${prop.formula?.expression || ""}`;
108 | break;
109 | case "rollup":
110 | details = `Rollup: ${prop.rollup?.function || ""}`;
111 | break;
112 | case "created_by":
113 | case "last_edited_by":
114 | details = "User reference";
115 | break;
116 | case "created_time":
117 | case "last_edited_time":
118 | details = "Timestamp";
119 | break;
120 | case "date":
121 | details = "Date or date range";
122 | break;
123 | case "email":
124 | details = "Email address";
125 | break;
126 | case "files":
127 | details = "File attachments";
128 | break;
129 | case "number":
130 | details = `Format: ${prop.number?.format || "plain number"}`;
131 | break;
132 | case "people":
133 | details = "People reference";
134 | break;
135 | case "phone_number":
136 | details = "Phone number";
137 | break;
138 | case "rich_text":
139 | details = "Formatted text";
140 | break;
141 | case "status":
142 | const statusOptions = prop.status?.options || [];
143 | details = `Options: ${statusOptions
144 | .map((o: any) => o.name)
145 | .join(", ")}`;
146 | break;
147 | case "title":
148 | details = "Database title";
149 | break;
150 | case "url":
151 | details = "URL link";
152 | break;
153 | case "checkbox":
154 | details = "Boolean value";
155 | break;
156 | }
157 |
158 | markdown += `| ${escapeTableCell(
159 | propName
160 | )} | ${propType} | ${escapeTableCell(details)} |\n`;
161 | });
162 |
163 | markdown += "\n";
164 | }
165 |
166 | // Add link to view the database in Notion
167 | if (database.url) {
168 | markdown += `\n[View in Notion](${database.url})\n`;
169 | }
170 |
171 | return markdown;
172 | }
173 |
174 | /**
175 | * Converts Notion API block response to Markdown
176 | */
177 | function convertBlockToMarkdown(block: BlockResponse): string {
178 | if (!block) return "";
179 |
180 | // Convert based on block type
181 | return renderBlock(block);
182 | }
183 |
184 | /**
185 | * Converts list response (search results or block children) to Markdown
186 | */
187 | function convertListToMarkdown(list: ListResponse): string {
188 | if (!list || !list.results || !Array.isArray(list.results)) {
189 | return "```\nNo results\n```";
190 | }
191 |
192 | let markdown = "";
193 |
194 | // Determine the type of results
195 | const firstResult = list.results[0];
196 | const resultType = firstResult?.object || "unknown";
197 |
198 | // Add header based on type
199 | switch (resultType) {
200 | case "page":
201 | markdown += "# Search Results (Pages)\n\n";
202 | break;
203 | case "database":
204 | markdown += "# Search Results (Databases)\n\n";
205 | break;
206 | case "block":
207 | markdown += "# Block Contents\n\n";
208 | break;
209 | default:
210 | markdown += "# Results List\n\n";
211 | }
212 |
213 | // Process each result
214 | for (const item of list.results) {
215 | // Convert based on type
216 | switch (item.object) {
217 | case "page":
218 | if (resultType === "page") {
219 | // Display page title and link
220 | const title = extractPageTitle(item as PageResponse) || "Untitled";
221 | markdown += `## [${title}](${(item as PageResponse).url || "#"})\n\n`;
222 | markdown += `ID: \`${item.id}\`\n\n`;
223 | // Separator line
224 | markdown += "---\n\n";
225 | } else {
226 | // Full conversion
227 | markdown += convertPageToMarkdown(item as PageResponse);
228 | markdown += "\n\n---\n\n";
229 | }
230 | break;
231 |
232 | case "database":
233 | if (resultType === "database") {
234 | // Simple display
235 | const dbTitle =
236 | extractRichText((item as DatabaseResponse).title || []) ||
237 | "Untitled Database";
238 | markdown += `## [${dbTitle}](${
239 | (item as DatabaseResponse).url || "#"
240 | })\n\n`;
241 | markdown += `ID: \`${item.id}\`\n\n`;
242 | markdown += "---\n\n";
243 | } else {
244 | // Full conversion
245 | markdown += convertDatabaseToMarkdown(item as DatabaseResponse);
246 | markdown += "\n\n---\n\n";
247 | }
248 | break;
249 |
250 | case "block":
251 | markdown += renderBlock(item as BlockResponse);
252 | markdown += "\n\n";
253 | break;
254 |
255 | default:
256 | markdown += `\`\`\`json\n${JSON.stringify(item, null, 2)}\n\`\`\`\n\n`;
257 | }
258 | }
259 |
260 | // Include pagination info if available
261 | if (list.has_more) {
262 | markdown +=
263 | "\n> More results available. Use `start_cursor` parameter with the next request.\n";
264 | if (list.next_cursor) {
265 | markdown += `> Next cursor: \`${list.next_cursor}\`\n`;
266 | }
267 | }
268 |
269 | return markdown;
270 | }
271 |
272 | /**
273 | * Extracts page title
274 | */
275 | function extractPageTitle(page: PageResponse): string {
276 | if (!page || !page.properties) return "";
277 |
278 | // Look for the title property
279 | for (const [_, prop] of Object.entries(page.properties)) {
280 | const property = prop as PageProperty;
281 | if (property.type === "title" && Array.isArray(property.title)) {
282 | return extractRichText(property.title);
283 | }
284 | }
285 |
286 | return "";
287 | }
288 |
289 | /**
290 | * Converts page properties to Markdown
291 | */
292 | function convertPropertiesToMarkdown(
293 | properties: Record<string, PageProperty>
294 | ): string {
295 | if (!properties) return "";
296 |
297 | let markdown = "## Properties\n\n";
298 |
299 | // Display properties as a key-value table
300 | markdown += "| Property | Value |\n";
301 | markdown += "|------------|----|\n";
302 |
303 | for (const [key, prop] of Object.entries(properties)) {
304 | const property = prop as PageProperty;
305 | const propName = key;
306 | let propValue = "";
307 |
308 | // Extract value based on property type
309 | switch (property.type) {
310 | case "title":
311 | propValue = extractRichText(property.title || []);
312 | break;
313 | case "rich_text":
314 | propValue = extractRichText(property.rich_text || []);
315 | break;
316 | case "number":
317 | propValue = property.number?.toString() || "";
318 | break;
319 | case "select":
320 | propValue = property.select?.name || "";
321 | break;
322 | case "multi_select":
323 | propValue = (property.multi_select || [])
324 | .map((item: any) => item.name)
325 | .join(", ");
326 | break;
327 | case "date":
328 | const start = property.date?.start || "";
329 | const end = property.date?.end ? ` → ${property.date.end}` : "";
330 | propValue = start + end;
331 | break;
332 | case "people":
333 | propValue = (property.people || [])
334 | .map((person: any) => person.name || person.id)
335 | .join(", ");
336 | break;
337 | case "files":
338 | propValue = (property.files || [])
339 | .map(
340 | (file: any) =>
341 | `[${file.name || "Attachment"}](${
342 | file.file?.url || file.external?.url || "#"
343 | })`
344 | )
345 | .join(", ");
346 | break;
347 | case "checkbox":
348 | propValue = property.checkbox ? "✓" : "✗";
349 | break;
350 | case "url":
351 | propValue = property.url || "";
352 | break;
353 | case "email":
354 | propValue = property.email || "";
355 | break;
356 | case "phone_number":
357 | propValue = property.phone_number || "";
358 | break;
359 | case "formula":
360 | propValue =
361 | property.formula?.string ||
362 | property.formula?.number?.toString() ||
363 | property.formula?.boolean?.toString() ||
364 | "";
365 | break;
366 | case "status":
367 | propValue = property.status?.name || "";
368 | break;
369 | case "relation":
370 | propValue = (property.relation || [])
371 | .map((relation: any) => `\`${relation.id}\``)
372 | .join(", ");
373 | break;
374 | case "rollup":
375 | if (property.rollup?.type === "array") {
376 | propValue = JSON.stringify(property.rollup.array || []);
377 | } else {
378 | propValue =
379 | property.rollup?.number?.toString() ||
380 | property.rollup?.date?.start ||
381 | property.rollup?.string ||
382 | "";
383 | }
384 | break;
385 | case "created_by":
386 | propValue = property.created_by?.name || property.created_by?.id || "";
387 | break;
388 | case "created_time":
389 | propValue = property.created_time || "";
390 | break;
391 | case "last_edited_by":
392 | propValue =
393 | property.last_edited_by?.name || property.last_edited_by?.id || "";
394 | break;
395 | case "last_edited_time":
396 | propValue = property.last_edited_time || "";
397 | break;
398 | default:
399 | propValue = "(Unsupported property type)";
400 | }
401 |
402 | markdown += `| ${escapeTableCell(propName)} | ${escapeTableCell(
403 | propValue
404 | )} |\n`;
405 | }
406 |
407 | return markdown;
408 | }
409 |
410 | /**
411 | * Extracts plain text from a Notion rich text array
412 | */
413 | function extractRichText(richTextArray: RichTextItemResponse[]): string {
414 | if (!richTextArray || !Array.isArray(richTextArray)) return "";
415 |
416 | return richTextArray
417 | .map((item) => {
418 | let text = item.plain_text || "";
419 |
420 | // Process annotations
421 | if (item.annotations) {
422 | const { bold, italic, strikethrough, code } = item.annotations;
423 |
424 | if (code) text = `\`${text}\``;
425 | if (bold) text = `**${text}**`;
426 | if (italic) text = `*${text}*`;
427 | if (strikethrough) text = `~~${text}~~`;
428 | }
429 |
430 | // Process links
431 | if (item.href) {
432 | text = `[${text}](${item.href})`;
433 | }
434 |
435 | return text;
436 | })
437 | .join("");
438 | }
439 |
440 | /**
441 | * Converts a block to Markdown
442 | */
443 | function renderBlock(block: BlockResponse): string {
444 | if (!block) return "";
445 |
446 | const blockType = block.type;
447 | if (!blockType) return "";
448 |
449 | // Get block content
450 | const blockContent = block[blockType];
451 | if (!blockContent && blockType !== "divider") return "";
452 |
453 | switch (blockType) {
454 | case "paragraph":
455 | return renderParagraph(blockContent);
456 |
457 | case "heading_1":
458 | return `# ${extractRichText(blockContent.rich_text || [])}`;
459 |
460 | case "heading_2":
461 | return `## ${extractRichText(blockContent.rich_text || [])}`;
462 |
463 | case "heading_3":
464 | return `### ${extractRichText(blockContent.rich_text || [])}`;
465 |
466 | case "bulleted_list_item":
467 | return `- ${extractRichText(blockContent.rich_text || [])}`;
468 |
469 | case "numbered_list_item":
470 | return `1. ${extractRichText(blockContent.rich_text || [])}`;
471 |
472 | case "to_do":
473 | const checked = blockContent.checked ? "x" : " ";
474 | return `- [${checked}] ${extractRichText(blockContent.rich_text || [])}`;
475 |
476 | case "toggle":
477 | return `<details>\n<summary>${extractRichText(
478 | blockContent.rich_text || []
479 | )}</summary>\n\n*Additional API request is needed to display child blocks*\n\n</details>`;
480 |
481 | case "child_page":
482 | return `📄 **Child Page**: ${blockContent.title || "Untitled"}`;
483 |
484 | case "image":
485 | const imageType = blockContent.type || "";
486 | const imageUrl =
487 | imageType === "external"
488 | ? blockContent.external?.url
489 | : blockContent.file?.url;
490 | const imageCaption =
491 | extractRichText(blockContent.caption || []) || "image";
492 | return ``;
493 |
494 | case "divider":
495 | return "---";
496 |
497 | case "quote":
498 | return `> ${extractRichText(blockContent.rich_text || [])}`;
499 |
500 | case "code":
501 | const codeLanguage = blockContent.language || "plaintext";
502 | const codeContent = extractRichText(blockContent.rich_text || []);
503 | return `\`\`\`${codeLanguage}\n${codeContent}\n\`\`\``;
504 |
505 | case "callout":
506 | const calloutIcon = blockContent.icon?.emoji || "";
507 | const calloutText = extractRichText(blockContent.rich_text || []);
508 | return `> ${calloutIcon} ${calloutText}`;
509 |
510 | case "bookmark":
511 | const bookmarkUrl = blockContent.url || "";
512 | const bookmarkCaption =
513 | extractRichText(blockContent.caption || []) || bookmarkUrl;
514 | return `[${bookmarkCaption}](${bookmarkUrl})`;
515 |
516 | case "table":
517 | return `*Table data (${
518 | blockContent.table_width || 0
519 | } columns) - Additional API request is needed to display details*`;
520 |
521 | case "child_database":
522 | return `📊 **Embedded Database**: \`${block.id}\``;
523 |
524 | case "breadcrumb":
525 | return `[breadcrumb navigation]`;
526 |
527 | case "embed":
528 | const embedUrl = blockContent.url || "";
529 | return `<iframe src="${embedUrl}" frameborder="0"></iframe>`;
530 |
531 | case "equation":
532 | const formulaText = blockContent.expression || "";
533 | return `$$${formulaText}$$`;
534 |
535 | case "file":
536 | const fileType = blockContent.type || "";
537 | const fileUrl =
538 | fileType === "external"
539 | ? blockContent.external?.url
540 | : blockContent.file?.url;
541 | const fileName = blockContent.name || "File";
542 | return `📎 [${fileName}](${fileUrl || "#"})`;
543 |
544 | case "link_preview":
545 | const previewUrl = blockContent.url || "";
546 | return `🔗 [Preview](${previewUrl})`;
547 |
548 | case "link_to_page":
549 | let linkText = "Link to page";
550 | let linkId = "";
551 | if (blockContent.page_id) {
552 | linkId = blockContent.page_id;
553 | linkText = "Link to page";
554 | } else if (blockContent.database_id) {
555 | linkId = blockContent.database_id;
556 | linkText = "Link to database";
557 | }
558 | return `🔗 **${linkText}**: \`${linkId}\``;
559 |
560 | case "pdf":
561 | const pdfType = blockContent.type || "";
562 | const pdfUrl =
563 | pdfType === "external"
564 | ? blockContent.external?.url
565 | : blockContent.file?.url;
566 | const pdfCaption = extractRichText(blockContent.caption || []) || "PDF";
567 | return `📄 [${pdfCaption}](${pdfUrl || "#"})`;
568 |
569 | case "synced_block":
570 | const syncedFrom = blockContent.synced_from
571 | ? `\`${blockContent.synced_from.block_id}\``
572 | : "original";
573 | return `*Synced Block (${syncedFrom}) - Additional API request is needed to display content*`;
574 |
575 | case "table_of_contents":
576 | return `[TOC]`;
577 |
578 | case "table_row":
579 | if (!blockContent.cells || !Array.isArray(blockContent.cells)) {
580 | return "*Empty table row*";
581 | }
582 | return `| ${blockContent.cells
583 | .map((cell: any) => escapeTableCell(extractRichText(cell)))
584 | .join(" | ")} |`;
585 |
586 | case "template":
587 | return `*Template Block: ${extractRichText(
588 | blockContent.rich_text || []
589 | )}*`;
590 |
591 | case "video":
592 | const videoType = blockContent.type || "";
593 | const videoUrl =
594 | videoType === "external"
595 | ? blockContent.external?.url
596 | : blockContent.file?.url;
597 | const videoCaption =
598 | extractRichText(blockContent.caption || []) || "Video";
599 | return `🎬 [${videoCaption}](${videoUrl || "#"})`;
600 |
601 | case "unsupported":
602 | return `*Unsupported block*`;
603 |
604 | default:
605 | return `*Unsupported block type: ${blockType}*`;
606 | }
607 | }
608 |
609 | /**
610 | * Renders a paragraph block
611 | */
612 | function renderParagraph(paragraph: any): string {
613 | if (!paragraph || !paragraph.rich_text) return "";
614 |
615 | return extractRichText(paragraph.rich_text);
616 | }
617 |
618 | /**
619 | * Escapes characters that need special handling in Markdown table cells
620 | */
621 | function escapeTableCell(text: string): string {
622 | if (!text) return "";
623 | return text.replace(/\|/g, "\\|").replace(/\n/g, " ").replace(/\+/g, "\\+");
624 | }
625 |
```
--------------------------------------------------------------------------------
/src/markdown/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { expect, test, describe } from "vitest";
2 | import { convertToMarkdown } from "./index.js";
3 | import {
4 | PageResponse,
5 | BlockResponse,
6 | DatabaseResponse,
7 | ListResponse,
8 | } from "../types/index.js";
9 |
10 | describe("convertToMarkdown", () => {
11 | test("should handle null or undefined response", () => {
12 | // @ts-ignore - intentionally testing with null
13 | expect(convertToMarkdown(null)).toBe("");
14 | // @ts-ignore - intentionally testing with undefined
15 | expect(convertToMarkdown(undefined)).toBe("");
16 | });
17 |
18 | test("should convert a page response to markdown", () => {
19 | // ref: https://developers.notion.com/reference/page
20 | const pageResponse: PageResponse = {
21 | object: "page",
22 | id: "be633bf1-dfa0-436d-b259-571129a590e5",
23 | created_time: "2022-10-24T22:54:00.000Z",
24 | last_edited_time: "2023-03-08T18:25:00.000Z",
25 | created_by: {
26 | object: "user",
27 | id: "c2f20311-9e54-4d11-8c79-7398424ae41e",
28 | },
29 | last_edited_by: {
30 | object: "user",
31 | id: "9188c6a5-7381-452f-b3dc-d4865aa89bdf",
32 | },
33 | cover: null,
34 | icon: {
35 | type: "emoji",
36 | emoji: "🐞",
37 | },
38 | parent: {
39 | type: "database_id",
40 | database_id: "a1d8501e-1ac1-43e9-a6bd-ea9fe6c8822b",
41 | },
42 | archived: true,
43 | in_trash: true,
44 | properties: {
45 | "Due date": {
46 | id: "M%3BBw",
47 | type: "date",
48 | date: {
49 | start: "2023-02-23",
50 | end: null,
51 | time_zone: null,
52 | },
53 | },
54 | Status: {
55 | id: "Z%3ClH",
56 | type: "status",
57 | status: {
58 | id: "86ddb6ec-0627-47f8-800d-b65afd28be13",
59 | name: "Not started",
60 | color: "default",
61 | },
62 | },
63 | Title: {
64 | id: "title",
65 | type: "title",
66 | title: [
67 | {
68 | type: "text",
69 | text: {
70 | content: "Bug bash",
71 | link: null,
72 | },
73 | annotations: {
74 | bold: false,
75 | italic: false,
76 | strikethrough: false,
77 | underline: false,
78 | code: false,
79 | color: "default",
80 | },
81 | plain_text: "Bug bash",
82 | href: null,
83 | },
84 | ],
85 | },
86 | },
87 | url: "https://www.notion.so/Bug-bash-be633bf1dfa0436db259571129a590e5",
88 | public_url:
89 | "https://jm-testing.notion.site/p1-6df2c07bfc6b4c46815ad205d132e22d",
90 | };
91 |
92 | const markdown = convertToMarkdown(pageResponse);
93 |
94 | // More detailed verification
95 | expect(markdown).toMatch(/^# Bug bash\n\n/); // Check if title is correctly processed
96 | expect(markdown).toMatch(/## Properties\n\n/); // Check if properties section exists
97 | expect(markdown).toMatch(/\| Property \| Value \|\n\|\-+\|\-+\|/); // Check if property table header is correct
98 | expect(markdown).toMatch(/\| Due date \| 2023-02-23 \|/); // Check if date property is correctly displayed
99 | expect(markdown).toMatch(/\| Status \| Not started \|/); // Check if status property is correctly displayed
100 | expect(markdown).toMatch(/\| Title \| Bug bash \|/); // Check if title property is correctly displayed
101 | expect(markdown).toMatch(/> This page contains child blocks/); // Check if note about child blocks exists
102 | expect(markdown).toMatch(
103 | /> Block ID: `be633bf1-dfa0-436d-b259-571129a590e5`/
104 | ); // Check if block ID is correctly displayed
105 | expect(markdown).toMatch(
106 | /\[View in Notion\]\(https:\/\/www\.notion\.so\/Bug-bash-be633bf1dfa0436db259571129a590e5\)/
107 | ); // Check if link to Notion is correctly displayed
108 | });
109 |
110 | test("should convert a block response to markdown", () => {
111 | // ref: https://developers.notion.com/reference/block
112 | const blockResponse: BlockResponse = {
113 | object: "block",
114 | id: "c02fc1d3-db8b-45c5-a222-27595b15aea7",
115 | parent: {
116 | type: "page_id",
117 | page_id: "59833787-2cf9-4fdf-8782-e53db20768a5",
118 | },
119 | created_time: "2022-03-01T19:05:00.000Z",
120 | last_edited_time: "2022-07-06T19:41:00.000Z",
121 | created_by: {
122 | object: "user",
123 | id: "ee5f0f84-409a-440f-983a-a5315961c6e4",
124 | },
125 | last_edited_by: {
126 | object: "user",
127 | id: "ee5f0f84-409a-440f-983a-a5315961c6e4",
128 | },
129 | has_children: false,
130 | archived: false,
131 | in_trash: false,
132 | type: "heading_2",
133 | heading_2: {
134 | rich_text: [
135 | {
136 | type: "text",
137 | text: {
138 | content: "Lacinato kale",
139 | link: null,
140 | },
141 | annotations: {
142 | bold: false,
143 | italic: false,
144 | strikethrough: false,
145 | underline: false,
146 | code: false,
147 | color: "green",
148 | },
149 | plain_text: "Lacinato kale",
150 | href: null,
151 | },
152 | ],
153 | color: "default",
154 | is_toggleable: false,
155 | },
156 | };
157 |
158 | const markdown = convertToMarkdown(blockResponse);
159 |
160 | // Check if it's correctly displayed as a heading 2
161 | expect(markdown).toBe("## Lacinato kale");
162 | });
163 |
164 | test("should convert a database response to markdown", () => {
165 | // ref: https://developers.notion.com/reference/create-a-database response 200 - Result
166 | const databaseResponse: DatabaseResponse = {
167 | object: "database",
168 | id: "bc1211ca-e3f1-4939-ae34-5260b16f627c",
169 | created_time: "2021-07-08T23:50:00.000Z",
170 | last_edited_time: "2021-07-08T23:50:00.000Z",
171 | icon: {
172 | type: "emoji",
173 | emoji: "🎉",
174 | },
175 | cover: {
176 | type: "external",
177 | external: {
178 | url: "https://website.domain/images/image.png",
179 | },
180 | },
181 | url: "https://www.notion.so/bc1211cae3f14939ae34260b16f627c",
182 | title: [
183 | {
184 | type: "text",
185 | text: {
186 | content: "Grocery List",
187 | link: null,
188 | },
189 | annotations: {
190 | bold: false,
191 | italic: false,
192 | strikethrough: false,
193 | underline: false,
194 | code: false,
195 | color: "default",
196 | },
197 | plain_text: "Grocery List",
198 | href: null,
199 | },
200 | ],
201 | properties: {
202 | "+1": {
203 | id: "Wp%3DC",
204 | name: "+1",
205 | type: "people",
206 | people: {},
207 | },
208 | "In stock": {
209 | id: "fk%5EY",
210 | name: "In stock",
211 | type: "checkbox",
212 | checkbox: {},
213 | },
214 | Price: {
215 | id: "evWq",
216 | name: "Price",
217 | type: "number",
218 | number: {
219 | format: "dollar",
220 | },
221 | },
222 | Description: {
223 | id: "V}lX",
224 | name: "Description",
225 | type: "rich_text",
226 | rich_text: {},
227 | },
228 | "Last ordered": {
229 | id: "eVnV",
230 | name: "Last ordered",
231 | type: "date",
232 | date: {},
233 | },
234 | Meals: {
235 | id: "%7DWA~",
236 | name: "Meals",
237 | type: "relation",
238 | relation: {
239 | database_id: "668d797c-76fa-4934-9b05-ad288df2d136",
240 | synced_property_name: "Related to Grocery List (Meals)",
241 | },
242 | },
243 | "Number of meals": {
244 | id: "Z\\Eh",
245 | name: "Number of meals",
246 | type: "rollup",
247 | rollup: {
248 | rollup_property_name: "Name",
249 | relation_property_name: "Meals",
250 | rollup_property_id: "title",
251 | relation_property_id: "mxp^",
252 | function: "count",
253 | },
254 | },
255 | "Store availability": {
256 | id: "s}Kq",
257 | name: "Store availability",
258 | type: "multi_select",
259 | multi_select: {
260 | options: [
261 | {
262 | id: "cb79b393-d1c1-4528-b517-c450859de766",
263 | name: "Duc Loi Market",
264 | color: "blue",
265 | },
266 | {
267 | id: "58aae162-75d4-403b-a793-3bc7308e4cd2",
268 | name: "Rainbow Grocery",
269 | color: "gray",
270 | },
271 | {
272 | id: "22d0f199-babc-44ff-bd80-a9eae3e3fcbf",
273 | name: "Nijiya Market",
274 | color: "purple",
275 | },
276 | {
277 | id: "0d069987-ffb0-4347-bde2-8e4068003dbc",
278 | name: "Gus's Community Market",
279 | color: "yellow",
280 | },
281 | ],
282 | },
283 | },
284 | Photo: {
285 | id: "yfiK",
286 | name: "Photo",
287 | type: "files",
288 | files: {},
289 | },
290 | "Food group": {
291 | id: "CM%3EH",
292 | name: "Food group",
293 | type: "select",
294 | select: {
295 | options: [
296 | {
297 | id: "6d4523fa-88cb-4ffd-9364-1e39d0f4e566",
298 | name: "🥦Vegetable",
299 | color: "green",
300 | },
301 | {
302 | id: "268d7e75-de8f-4c4b-8b9d-de0f97021833",
303 | name: "🍎Fruit",
304 | color: "red",
305 | },
306 | {
307 | id: "1b234a00-dc97-489c-b987-829264cfdfef",
308 | name: "💪Protein",
309 | color: "yellow",
310 | },
311 | ],
312 | },
313 | },
314 | Name: {
315 | id: "title",
316 | name: "Name",
317 | type: "title",
318 | title: {},
319 | },
320 | },
321 | parent: {
322 | type: "page_id",
323 | page_id: "98ad959b-2b6a-4774-80ee-00246fb0ea9b",
324 | },
325 | archived: false,
326 | is_inline: false,
327 | };
328 |
329 | const markdown = convertToMarkdown(databaseResponse);
330 |
331 | // More detailed verification
332 | expect(markdown).toMatch(/^# Grocery List \(Database\)\n\n/); // Check if title is correctly processed
333 | expect(markdown).toMatch(/## Properties\n\n/); // Check if properties section exists
334 | expect(markdown).toMatch(
335 | /\| Property Name \| Type \| Details \|\n\|\-+\|\-+\|\-+\|/
336 | ); // Check if property table is correct
337 |
338 | // Check if each property is correctly displayed
339 | expect(markdown).toMatch(/\| \\\+1 \| people \| /); // +1 property
340 | expect(markdown).toMatch(/\| In stock \| checkbox \| /); // In stock property
341 | expect(markdown).toMatch(/\| Price \| number \| /); // Price property
342 | expect(markdown).toMatch(
343 | /\| Store availability \| multi_select \| Options: Duc Loi Market, Rainbow Grocery, Nijiya Market, Gus's Community Market \|/
344 | ); // Property with options
345 | expect(markdown).toMatch(
346 | /\| Food group \| select \| Options: 🥦Vegetable, 🍎Fruit, 💪Protein \|/
347 | ); // Options with emoji
348 | expect(markdown).toMatch(
349 | /\| Meals \| relation \| Related DB: 668d797c-76fa-4934-9b05-ad288df2d136 \|/
350 | ); // Relation
351 |
352 | // Check if link to Notion is correctly displayed
353 | expect(markdown).toMatch(
354 | /\[View in Notion\]\(https:\/\/www\.notion\.so\/bc1211cae3f14939ae34260b16f627c\)/
355 | );
356 | });
357 |
358 | test("should convert a list response to markdown", () => {
359 | // ref: https://developers.notion.com/reference/post-search response 200 - Result
360 | const listResponse: ListResponse = {
361 | object: "list",
362 | results: [
363 | {
364 | object: "page",
365 | id: "954b67f9-3f87-41db-8874-23b92bbd31ee",
366 | created_time: "2022-07-06T19:30:00.000Z",
367 | last_edited_time: "2022-07-06T19:30:00.000Z",
368 | created_by: {
369 | object: "user",
370 | id: "ee5f0f84-409a-440f-983a-a5315961c6e4",
371 | },
372 | last_edited_by: {
373 | object: "user",
374 | id: "ee5f0f84-409a-440f-983a-a5315961c6e4",
375 | },
376 | cover: {
377 | type: "external",
378 | external: {
379 | url: "https://upload.wikimedia.org/wikipedia/commons/6/62/Tuscankale.jpg",
380 | },
381 | },
382 | icon: {
383 | type: "emoji",
384 | emoji: "🥬",
385 | },
386 | parent: {
387 | type: "database_id",
388 | database_id: "d9824bdc-8445-4327-be8b-5b47500af6ce",
389 | },
390 | archived: false,
391 | properties: {
392 | "Store availability": {
393 | id: "%3AUPp",
394 | type: "multi_select",
395 | multi_select: [],
396 | },
397 | "Food group": {
398 | id: "A%40Hk",
399 | type: "select",
400 | select: {
401 | id: "5e8e7e8f-432e-4d8a-8166-1821e10225fc",
402 | name: "🥬 Vegetable",
403 | color: "pink",
404 | },
405 | },
406 | Price: {
407 | id: "BJXS",
408 | type: "number",
409 | number: null,
410 | },
411 | "Responsible Person": {
412 | id: "Iowm",
413 | type: "people",
414 | people: [],
415 | },
416 | "Last ordered": {
417 | id: "Jsfb",
418 | type: "date",
419 | date: null,
420 | },
421 | "Cost of next trip": {
422 | id: "WOd%3B",
423 | type: "formula",
424 | formula: {
425 | type: "number",
426 | number: null,
427 | },
428 | },
429 | Recipes: {
430 | id: "YfIu",
431 | type: "relation",
432 | relation: [],
433 | },
434 | Description: {
435 | id: "_Tc_",
436 | type: "rich_text",
437 | rich_text: [
438 | {
439 | type: "text",
440 | text: {
441 | content: "A dark green leafy vegetable",
442 | link: null,
443 | },
444 | annotations: {
445 | bold: false,
446 | italic: false,
447 | strikethrough: false,
448 | underline: false,
449 | code: false,
450 | color: "default",
451 | },
452 | plain_text: "A dark green leafy vegetable",
453 | href: null,
454 | },
455 | ],
456 | },
457 | "In stock": {
458 | id: "%60%5Bq%3F",
459 | type: "checkbox",
460 | checkbox: false,
461 | },
462 | "Number of meals": {
463 | id: "zag~",
464 | type: "rollup",
465 | rollup: {
466 | type: "number",
467 | number: 0,
468 | function: "count",
469 | },
470 | },
471 | Photo: {
472 | id: "%7DF_L",
473 | type: "url",
474 | url: null,
475 | },
476 | Name: {
477 | id: "title",
478 | type: "title",
479 | title: [
480 | {
481 | type: "text",
482 | text: {
483 | content: "Tuscan kale",
484 | link: null,
485 | },
486 | annotations: {
487 | bold: false,
488 | italic: false,
489 | strikethrough: false,
490 | underline: false,
491 | code: false,
492 | color: "default",
493 | },
494 | plain_text: "Tuscan kale",
495 | href: null,
496 | },
497 | ],
498 | },
499 | },
500 | url: "https://www.notion.so/Tuscan-kale-954b67f93f8741db887423b92bbd31ee",
501 | },
502 | {
503 | object: "page",
504 | id: "59833787-2cf9-4fdf-8782-e53db20768a5",
505 | created_time: "2022-03-01T19:05:00.000Z",
506 | last_edited_time: "2022-07-06T20:25:00.000Z",
507 | created_by: {
508 | object: "user",
509 | id: "ee5f0f84-409a-440f-983a-a5315961c6e4",
510 | },
511 | last_edited_by: {
512 | object: "user",
513 | id: "0c3e9826-b8f7-4f73-927d-2caaf86f1103",
514 | },
515 | cover: {
516 | type: "external",
517 | external: {
518 | url: "https://upload.wikimedia.org/wikipedia/commons/6/62/Tuscankale.jpg",
519 | },
520 | },
521 | icon: {
522 | type: "emoji",
523 | emoji: "🥬",
524 | },
525 | parent: {
526 | type: "database_id",
527 | database_id: "d9824bdc-8445-4327-be8b-5b47500af6ce",
528 | },
529 | archived: false,
530 | properties: {
531 | "Store availability": {
532 | id: "%3AUPp",
533 | type: "multi_select",
534 | multi_select: [
535 | {
536 | id: "t|O@",
537 | name: "Gus's Community Market",
538 | color: "yellow",
539 | },
540 | {
541 | id: "{Ml\\",
542 | name: "Rainbow Grocery",
543 | color: "gray",
544 | },
545 | ],
546 | },
547 | "Food group": {
548 | id: "A%40Hk",
549 | type: "select",
550 | select: {
551 | id: "5e8e7e8f-432e-4d8a-8166-1821e10225fc",
552 | name: "🥬 Vegetable",
553 | color: "pink",
554 | },
555 | },
556 | Price: {
557 | id: "BJXS",
558 | type: "number",
559 | number: 2.5,
560 | },
561 | "Responsible Person": {
562 | id: "Iowm",
563 | type: "people",
564 | people: [
565 | {
566 | object: "user",
567 | id: "cbfe3c6e-71cf-4cd3-b6e7-02f38f371bcc",
568 | name: "Cristina Cordova",
569 | avatar_url:
570 | "https://lh6.googleusercontent.com/-rapvfCoTq5A/AAAAAAAAAAI/AAAAAAAAAAA/AKF05nDKmmUpkpFvWNBzvu9rnZEy7cbl8Q/photo.jpg",
571 | type: "person",
572 | person: {
573 | email: "[email protected]",
574 | },
575 | },
576 | ],
577 | },
578 | "Last ordered": {
579 | id: "Jsfb",
580 | type: "date",
581 | date: {
582 | start: "2022-02-22",
583 | end: null,
584 | time_zone: null,
585 | },
586 | },
587 | "Cost of next trip": {
588 | id: "WOd%3B",
589 | type: "formula",
590 | formula: {
591 | type: "number",
592 | number: 0,
593 | },
594 | },
595 | Recipes: {
596 | id: "YfIu",
597 | type: "relation",
598 | relation: [
599 | {
600 | id: "90eeeed8-2cdd-4af4-9cc1-3d24aff5f63c",
601 | },
602 | {
603 | id: "a2da43ee-d43c-4285-8ae2-6d811f12629a",
604 | },
605 | ],
606 | has_more: false,
607 | },
608 | Description: {
609 | id: "_Tc_",
610 | type: "rich_text",
611 | rich_text: [
612 | {
613 | type: "text",
614 | text: {
615 | content: "A dark ",
616 | link: null,
617 | },
618 | annotations: {
619 | bold: false,
620 | italic: false,
621 | strikethrough: false,
622 | underline: false,
623 | code: false,
624 | color: "default",
625 | },
626 | plain_text: "A dark ",
627 | href: null,
628 | },
629 | {
630 | type: "text",
631 | text: {
632 | content: "green",
633 | link: null,
634 | },
635 | annotations: {
636 | bold: false,
637 | italic: false,
638 | strikethrough: false,
639 | underline: false,
640 | code: false,
641 | color: "green",
642 | },
643 | plain_text: "green",
644 | href: null,
645 | },
646 | {
647 | type: "text",
648 | text: {
649 | content: " leafy vegetable",
650 | link: null,
651 | },
652 | annotations: {
653 | bold: false,
654 | italic: false,
655 | strikethrough: false,
656 | underline: false,
657 | code: false,
658 | color: "default",
659 | },
660 | plain_text: " leafy vegetable",
661 | href: null,
662 | },
663 | ],
664 | },
665 | "In stock": {
666 | id: "%60%5Bq%3F",
667 | type: "checkbox",
668 | checkbox: true,
669 | },
670 | "Number of meals": {
671 | id: "zag~",
672 | type: "rollup",
673 | rollup: {
674 | type: "number",
675 | number: 2,
676 | function: "count",
677 | },
678 | },
679 | Photo: {
680 | id: "%7DF_L",
681 | type: "url",
682 | url: "https://i.insider.com/612fb23c9ef1e50018f93198?width=1136&format=jpeg",
683 | },
684 | Name: {
685 | id: "title",
686 | type: "title",
687 | title: [
688 | {
689 | type: "text",
690 | text: {
691 | content: "Tuscan kale",
692 | link: null,
693 | },
694 | annotations: {
695 | bold: false,
696 | italic: false,
697 | strikethrough: false,
698 | underline: false,
699 | code: false,
700 | color: "default",
701 | },
702 | plain_text: "Tuscan kale",
703 | href: null,
704 | },
705 | ],
706 | },
707 | },
708 | url: "https://www.notion.so/Tuscan-kale-598337872cf94fdf8782e53db20768a5",
709 | },
710 | ],
711 | next_cursor: null,
712 | has_more: false,
713 | type: "page_or_database",
714 | page_or_database: {},
715 | };
716 |
717 | const markdown = convertToMarkdown(listResponse);
718 |
719 | // More detailed verification
720 | expect(markdown).toMatch(/^# Search Results \(Pages\)\n\n/); // Check if header is correct
721 |
722 | // Check if title and link for each page in the search results are included
723 | expect(markdown).toMatch(
724 | /## \[Tuscan kale\]\(https:\/\/www\.notion\.so\/Tuscan-kale-954b67f93f8741db887423b92bbd31ee\)/
725 | ); // First page
726 | expect(markdown).toMatch(/ID: `954b67f9-3f87-41db-8874-23b92bbd31ee`/); // First page ID
727 |
728 | expect(markdown).toMatch(
729 | /## \[Tuscan kale\]\(https:\/\/www\.notion\.so\/Tuscan-kale-598337872cf94fdf8782e53db20768a5\)/
730 | ); // Second page
731 | expect(markdown).toMatch(/ID: `59833787-2cf9-4fdf-8782-e53db20768a5`/); // Second page ID
732 |
733 | // Check if each result is separated by a divider line
734 | expect(markdown).toMatch(/---\n\n/);
735 |
736 | // Check that pagination info is not present (because has_more is false)
737 | expect(markdown).not.toMatch(/More results available/);
738 | });
739 |
740 | test("should convert unknown object type to JSON", () => {
741 | const unknownResponse = {
742 | object: "unknown",
743 | id: "unknown123",
744 | };
745 |
746 | // @ts-ignore - intentionally testing with unknown type
747 | const markdown = convertToMarkdown(unknownResponse);
748 |
749 | expect(markdown).toMatch(/^```json\n/); // JSON code block start
750 | expect(markdown).toMatch(/"object": "unknown"/); // Object type
751 | expect(markdown).toMatch(/"id": "unknown123"/); // ID
752 | expect(markdown).toMatch(/\n```$/); // JSON code block end
753 | });
754 | });
755 |
```