@devheerim/notion-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +36 -0
  5. package/dist/notion-client.d.ts +4 -0
  6. package/dist/notion-client.js +78 -0
  7. package/dist/tools/create-comment.d.ts +2 -0
  8. package/dist/tools/create-comment.js +54 -0
  9. package/dist/tools/create-database.d.ts +2 -0
  10. package/dist/tools/create-database.js +44 -0
  11. package/dist/tools/create-page.d.ts +2 -0
  12. package/dist/tools/create-page.js +60 -0
  13. package/dist/tools/duplicate-page.d.ts +2 -0
  14. package/dist/tools/duplicate-page.js +185 -0
  15. package/dist/tools/fetch-page.d.ts +2 -0
  16. package/dist/tools/fetch-page.js +120 -0
  17. package/dist/tools/get-comments.d.ts +2 -0
  18. package/dist/tools/get-comments.js +71 -0
  19. package/dist/tools/get-users.d.ts +2 -0
  20. package/dist/tools/get-users.js +86 -0
  21. package/dist/tools/move-pages.d.ts +2 -0
  22. package/dist/tools/move-pages.js +67 -0
  23. package/dist/tools/query-database.d.ts +2 -0
  24. package/dist/tools/query-database.js +53 -0
  25. package/dist/tools/search.d.ts +2 -0
  26. package/dist/tools/search.js +58 -0
  27. package/dist/tools/update-database.d.ts +2 -0
  28. package/dist/tools/update-database.js +71 -0
  29. package/dist/tools/update-page.d.ts +2 -0
  30. package/dist/tools/update-page.js +62 -0
  31. package/dist/utils/format-blocks.d.ts +1 -0
  32. package/dist/utils/format-blocks.js +234 -0
  33. package/dist/utils/format-properties.d.ts +1 -0
  34. package/dist/utils/format-properties.js +158 -0
  35. package/dist/utils/format-response.d.ts +3 -0
  36. package/dist/utils/format-response.js +85 -0
  37. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 devheerim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @devheerim/notion-mcp-server
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@devheerim/notion-mcp-server.svg)](https://www.npmjs.com/package/@devheerim/notion-mcp-server)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Notion 공식 API를 래핑한 MCP(Model Context Protocol) 서버입니다. 기본 제공되는 Notion MCP를 대체하며, **데이터베이스 쿼리 기능이 훨씬 더 강력합니다.**
7
+
8
+ ## 주요 기능
9
+
10
+ - **`query_database`**: 필터, 정렬, 페이지네이션을 완벽하게 지원하는 데이터베이스 쿼리
11
+ - **프로퍼티 기반 필터링**: 복잡한 AND/OR 조건으로 정밀한 데이터 조회
12
+ - **전체 12개 도구**: 페이지 CRUD, 댓글, 사용자 관리 등 포괄적인 기능
13
+
14
+ ## 설치
15
+
16
+ ```bash
17
+ npm install -g @devheerim/notion-mcp-server
18
+ ```
19
+
20
+ ## 설정
21
+
22
+ ### 1. Notion Integration 토큰 발급
23
+
24
+ 1. [Notion Integrations](https://www.notion.so/my-integrations)에서 새 통합 생성
25
+ 2. 생성된 **Internal Integration Secret** 복사 (`ntn_` 으로 시작)
26
+
27
+ ### 2. Notion에서 Integration 연결
28
+
29
+ 접근할 페이지/데이터베이스마다:
30
+ 1. 우측 상단 **...** > **연결** 클릭
31
+ 2. 생성한 Integration 선택
32
+
33
+ ### 3. MCP 클라이언트에 등록
34
+
35
+ #### Claude Desktop / Claude Code
36
+
37
+ `claude_desktop_config.json` 또는 `~/.claude/settings.json`에 추가:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "notion": {
43
+ "command": "npx",
44
+ "args": ["-y", "@devheerim/notion-mcp-server"],
45
+ "env": {
46
+ "NOTION_API_KEY": "ntn_your_token_here"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ #### 직접 실행
54
+
55
+ ```bash
56
+ NOTION_API_KEY=ntn_your_token_here notion-mcp-server
57
+ ```
58
+
59
+ ## 도구 목록
60
+
61
+ | 도구 | 설명 |
62
+ |------|------|
63
+ | `query_database` | 데이터베이스를 필터, 정렬, 페이지네이션으로 쿼리 |
64
+ | `fetch_page` | 페이지 상세 정보 및 블록 콘텐츠 조회 |
65
+ | `search` | 워크스페이스 전체 검색 |
66
+ | `create_page` | 새 페이지 생성 |
67
+ | `update_page` | 기존 페이지 수정 |
68
+ | `create_database` | 새 데이터베이스 생성 |
69
+ | `update_database` | 데이터베이스 스키마 및 속성 수정 |
70
+ | `create_comment` | 페이지에 댓글 추가 |
71
+ | `get_comments` | 페이지의 모든 댓글 조회 |
72
+ | `get_users` | 워크스페이스 사용자 조회 |
73
+ | `move_pages` | 페이지를 다른 위치로 이동 |
74
+ | `duplicate_page` | 기존 페이지 복제 |
75
+
76
+ ## 사용 예시
77
+
78
+ ### 상태가 "완료"인 작업 조회
79
+
80
+ ```
81
+ query_database:
82
+ database_id: "12345678901234567890123456789012"
83
+ filter:
84
+ property: "Status"
85
+ select:
86
+ equals: "완료"
87
+ ```
88
+
89
+ ### 복합 필터 + 정렬
90
+
91
+ ```
92
+ query_database:
93
+ database_id: "12345678901234567890123456789012"
94
+ filter:
95
+ and:
96
+ - property: "Priority"
97
+ select:
98
+ equals: "높음"
99
+ - property: "Status"
100
+ select:
101
+ equals: "미완료"
102
+ sorts:
103
+ - property: "Created"
104
+ direction: "descending"
105
+ ```
106
+
107
+ ### 날짜 기반 필터 + 페이지네이션
108
+
109
+ ```
110
+ query_database:
111
+ database_id: "12345678901234567890123456789012"
112
+ filter:
113
+ property: "Due Date"
114
+ date:
115
+ before: "2026-02-22"
116
+ page_size: 25
117
+ ```
118
+
119
+ ## 기술 스택
120
+
121
+ - **TypeScript** + **Node.js**
122
+ - **@modelcontextprotocol/sdk** - MCP 서버 프레임워크
123
+ - **@notionhq/client** - Notion 공식 API 클라이언트
124
+ - **Zod** - 입력 스키마 검증
125
+
126
+ ## 라이선스
127
+
128
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ // Import all tool registrations
5
+ import { register as registerQueryDatabase } from './tools/query-database.js';
6
+ import { register as registerFetchPage } from './tools/fetch-page.js';
7
+ import { register as registerSearch } from './tools/search.js';
8
+ import { register as registerCreatePage } from './tools/create-page.js';
9
+ import { register as registerUpdatePage } from './tools/update-page.js';
10
+ import { register as registerCreateDatabase } from './tools/create-database.js';
11
+ import { register as registerCreateComment } from './tools/create-comment.js';
12
+ import { register as registerGetComments } from './tools/get-comments.js';
13
+ import { register as registerGetUsers } from './tools/get-users.js';
14
+ import { register as registerMovePages } from './tools/move-pages.js';
15
+ import { register as registerDuplicatePage } from './tools/duplicate-page.js';
16
+ import { register as registerUpdateDatabase } from './tools/update-database.js';
17
+ const server = new McpServer({
18
+ name: 'notion-mcp-server',
19
+ version: '1.0.0',
20
+ });
21
+ // Register all tools
22
+ registerQueryDatabase(server);
23
+ registerFetchPage(server);
24
+ registerSearch(server);
25
+ registerCreatePage(server);
26
+ registerUpdatePage(server);
27
+ registerCreateDatabase(server);
28
+ registerCreateComment(server);
29
+ registerGetComments(server);
30
+ registerGetUsers(server);
31
+ registerMovePages(server);
32
+ registerDuplicatePage(server);
33
+ registerUpdateDatabase(server);
34
+ // Start server with stdio transport
35
+ const transport = new StdioServerTransport();
36
+ await server.connect(transport);
@@ -0,0 +1,4 @@
1
+ import { Client } from '@notionhq/client';
2
+ export declare function getNotionClient(): Client;
3
+ export declare function parseNotionId(input: string): string;
4
+ export declare function formatApiError(error: unknown): string;
@@ -0,0 +1,78 @@
1
+ import { Client } from '@notionhq/client';
2
+ let notionClient = null;
3
+ export function getNotionClient() {
4
+ if (!notionClient) {
5
+ const apiKey = process.env.NOTION_API_KEY;
6
+ if (!apiKey) {
7
+ throw new Error('NOTION_API_KEY environment variable is required');
8
+ }
9
+ notionClient = new Client({ auth: apiKey });
10
+ }
11
+ return notionClient;
12
+ }
13
+ export function parseNotionId(input) {
14
+ if (!input)
15
+ return input;
16
+ let cleaned = input.trim();
17
+ // Strip query params
18
+ const qIndex = cleaned.indexOf('?');
19
+ if (qIndex !== -1) {
20
+ cleaned = cleaned.slice(0, qIndex);
21
+ }
22
+ // Strip hash fragments
23
+ const hashIndex = cleaned.indexOf('#');
24
+ if (hashIndex !== -1) {
25
+ cleaned = cleaned.slice(0, hashIndex);
26
+ }
27
+ // Handle full Notion URLs:
28
+ // https://www.notion.so/workspace/Page-Title-abc123def456...
29
+ // https://notion.so/abc123def456...
30
+ // https://www.notion.so/abc123def456...
31
+ const urlPattern = /notion\.so\/(?:[^/]+\/)*([a-f0-9]{32}|[a-f0-9-]{36})\s*$/i;
32
+ const urlMatch = cleaned.match(urlPattern);
33
+ if (urlMatch) {
34
+ cleaned = urlMatch[1];
35
+ }
36
+ // Also handle the case where the ID is embedded at the end of a slug with dashes:
37
+ // e.g. "Page-Title-abc123def456" -> last 32 hex chars
38
+ const slugPattern = /[^a-f0-9]([a-f0-9]{32})$/i;
39
+ const slugMatch = cleaned.match(slugPattern);
40
+ if (slugMatch) {
41
+ cleaned = slugMatch[1];
42
+ }
43
+ // Strip dashes from UUID format: 12345678-90ab-cdef-1234-567890abcdef
44
+ const uuidWithDashes = /^([a-f0-9]{8})-([a-f0-9]{4})-([a-f0-9]{4})-([a-f0-9]{4})-([a-f0-9]{12})$/i;
45
+ if (uuidWithDashes.test(cleaned)) {
46
+ return cleaned.replace(/-/g, '');
47
+ }
48
+ // Already a 32-char hex string (UUID without dashes)
49
+ const uuidNoDashes = /^[a-f0-9]{32}$/i;
50
+ if (uuidNoDashes.test(cleaned)) {
51
+ return cleaned;
52
+ }
53
+ // Return as-is if we couldn't parse it (let Notion API produce the error)
54
+ return cleaned;
55
+ }
56
+ export function formatApiError(error) {
57
+ if (error && typeof error === 'object') {
58
+ const err = error;
59
+ // @notionhq/client throws APIResponseError with status, code, message
60
+ const status = err['status'];
61
+ const code = err['code'];
62
+ const message = err['message'];
63
+ if (status !== undefined && message) {
64
+ if (code) {
65
+ return `Notion API Error (${status}): [${code}] ${message}`;
66
+ }
67
+ return `Notion API Error (${status}): ${message}`;
68
+ }
69
+ if (message) {
70
+ return `Notion API Error: ${message}`;
71
+ }
72
+ // Fallback for generic Error instances
73
+ if (error instanceof Error) {
74
+ return `Error: ${error.message}`;
75
+ }
76
+ }
77
+ return `Unknown error: ${String(error)}`;
78
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { getNotionClient, parseNotionId, formatApiError } from '../notion-client.js';
3
+ export function register(server) {
4
+ server.tool('create_comment', '페이지에 댓글을 추가합니다.', {
5
+ page_id: z.string().describe('댓글을 추가할 페이지 ID 또는 URL'),
6
+ text: z.string().describe('댓글 내용 (plain text)'),
7
+ discussion_id: z
8
+ .string()
9
+ .optional()
10
+ .describe('기존 토론에 답글 달기 위한 discussion ID (선택)'),
11
+ }, async ({ page_id, text, discussion_id }) => {
12
+ try {
13
+ const notion = getNotionClient();
14
+ const parsedPageId = parseNotionId(page_id);
15
+ const rich_text = [
16
+ {
17
+ type: 'text',
18
+ text: { content: text },
19
+ },
20
+ ];
21
+ let response;
22
+ if (discussion_id) {
23
+ response = await notion.comments.create({
24
+ discussion_id,
25
+ rich_text,
26
+ });
27
+ }
28
+ else {
29
+ response = await notion.comments.create({
30
+ parent: { page_id: parsedPageId },
31
+ rich_text,
32
+ });
33
+ }
34
+ const lines = [
35
+ `## 댓글 추가 완료`,
36
+ `- 댓글 ID: ${response.id}`,
37
+ `- 페이지 ID: ${parsedPageId}`,
38
+ `- 내용: ${text}`,
39
+ `- 생성 시간: ${response.created_time ?? ''}`,
40
+ ];
41
+ if (discussion_id)
42
+ lines.push(`- 토론 ID: ${discussion_id}`);
43
+ return {
44
+ content: [{ type: 'text', text: lines.join('\n') }],
45
+ };
46
+ }
47
+ catch (error) {
48
+ return {
49
+ content: [{ type: 'text', text: formatApiError(error) }],
50
+ isError: true,
51
+ };
52
+ }
53
+ });
54
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { getNotionClient, parseNotionId, formatApiError } from '../notion-client.js';
3
+ export function register(server) {
4
+ server.tool('create_database', '새 데이터베이스를 생성합니다.', {
5
+ parent_page_id: z.string().describe('부모 페이지 ID 또는 URL'),
6
+ title: z.string().describe('데이터베이스 제목'),
7
+ properties: z
8
+ .record(z.any())
9
+ .describe('프로퍼티 스키마 정의. 예: {"Name": {"title": {}}, "Status": {"select": {"options": [{"name":"Todo","color":"red"}]}}}'),
10
+ }, async ({ parent_page_id, title, properties }) => {
11
+ try {
12
+ const notion = getNotionClient();
13
+ const parentId = parseNotionId(parent_page_id);
14
+ const response = await notion.databases.create({
15
+ parent: { type: 'page_id', page_id: parentId },
16
+ title: [
17
+ {
18
+ type: 'text',
19
+ text: { content: title },
20
+ },
21
+ ],
22
+ properties: properties,
23
+ });
24
+ const url = response.url ?? '';
25
+ const lines = [
26
+ `## 데이터베이스 생성 완료`,
27
+ `- ID: ${response.id}`,
28
+ `- 제목: ${title}`,
29
+ ];
30
+ if (url)
31
+ lines.push(`- URL: ${url}`);
32
+ lines.push(`- 프로퍼티 수: ${Object.keys(response.properties ?? {}).length}`);
33
+ return {
34
+ content: [{ type: 'text', text: lines.join('\n') }],
35
+ };
36
+ }
37
+ catch (error) {
38
+ return {
39
+ content: [{ type: 'text', text: formatApiError(error) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { getNotionClient, parseNotionId, formatApiError } from '../notion-client.js';
3
+ import { formatProperties } from '../utils/format-properties.js';
4
+ export function register(server) {
5
+ server.tool('create_page', '새 페이지를 생성합니다. 데이터베이스 내 항목 또는 독립 페이지 생성 가능.', {
6
+ parent_type: z
7
+ .enum(['database_id', 'page_id'])
8
+ .describe('부모 타입: "database_id" (DB 항목) 또는 "page_id" (하위 페이지)'),
9
+ parent_id: z.string().describe('부모 DB 또는 페이지의 ID / Notion URL'),
10
+ properties: z
11
+ .record(z.any())
12
+ .describe('페이지 프로퍼티 객체. 예시:\n' +
13
+ ' title: {"Name": {"title": [{"text": {"content": "제목"}}]}}\n' +
14
+ ' select: {"Status": {"select": {"name": "Done"}}}\n' +
15
+ ' multi_select: {"Tags": {"multi_select": [{"name": "A"}, {"name": "B"}]}}\n' +
16
+ ' date: {"Due": {"date": {"start": "2024-01-01"}}}\n' +
17
+ ' number: {"Count": {"number": 42}}\n' +
18
+ ' checkbox: {"Done": {"checkbox": true}}\n' +
19
+ ' url: {"Link": {"url": "https://example.com"}}'),
20
+ children: z
21
+ .array(z.record(z.any()))
22
+ .optional()
23
+ .describe('페이지 콘텐츠 블록 배열 (선택)'),
24
+ }, async ({ parent_type, parent_id, properties, children }) => {
25
+ try {
26
+ const notion = getNotionClient();
27
+ const parsedParentId = parseNotionId(parent_id);
28
+ const createParams = {
29
+ parent: { [parent_type]: parsedParentId },
30
+ properties,
31
+ };
32
+ if (children && children.length > 0) {
33
+ createParams.children = children;
34
+ }
35
+ const response = await notion.pages.create(createParams);
36
+ const formattedProps = formatProperties(response.properties ?? {});
37
+ const lines = [
38
+ '## 페이지 생성 완료',
39
+ `- ID: ${response.id}`,
40
+ `- URL: ${response.url ?? ''}`,
41
+ `- 생성 시간: ${response.created_time ?? ''}`,
42
+ '',
43
+ '### 프로퍼티',
44
+ ];
45
+ for (const [key, value] of Object.entries(formattedProps)) {
46
+ if (value)
47
+ lines.push(`- ${key}: ${value}`);
48
+ }
49
+ return {
50
+ content: [{ type: 'text', text: lines.join('\n') }],
51
+ };
52
+ }
53
+ catch (error) {
54
+ return {
55
+ content: [{ type: 'text', text: formatApiError(error) }],
56
+ isError: true,
57
+ };
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function register(server: McpServer): void;
@@ -0,0 +1,185 @@
1
+ import { z } from 'zod';
2
+ import { getNotionClient, parseNotionId, formatApiError } from '../notion-client.js';
3
+ // Block types that cannot be appended via the API
4
+ const UNSUPPORTED_BLOCK_TYPES = new Set([
5
+ 'synced_block',
6
+ 'child_page',
7
+ 'child_database',
8
+ 'template',
9
+ 'link_to_page',
10
+ ]);
11
+ async function fetchBlocksRecursive(notion, blockId, depth) {
12
+ if (depth <= 0)
13
+ return [];
14
+ const blocks = [];
15
+ let cursor = undefined;
16
+ do {
17
+ const params = { block_id: blockId, page_size: 100 };
18
+ if (cursor)
19
+ params['start_cursor'] = cursor;
20
+ const response = await notion.blocks.children.list(params);
21
+ const results = response?.results ?? [];
22
+ blocks.push(...results);
23
+ cursor = response?.has_more ? response.next_cursor ?? undefined : undefined;
24
+ } while (cursor);
25
+ // Recursively fetch children up to the given depth
26
+ for (const block of blocks) {
27
+ if (block.has_children && depth > 1) {
28
+ block._children = await fetchBlocksRecursive(notion, block.id, depth - 1);
29
+ }
30
+ }
31
+ return blocks;
32
+ }
33
+ function stripBlockForCreate(block) {
34
+ const type = block.type;
35
+ if (UNSUPPORTED_BLOCK_TYPES.has(type))
36
+ return null;
37
+ const content = block[type];
38
+ if (!content)
39
+ return null;
40
+ const newBlock = { type, [type]: { ...content } };
41
+ // Attach children if available
42
+ if (block._children && block._children.length > 0) {
43
+ const childBlocks = [];
44
+ for (const child of block._children) {
45
+ const stripped = stripBlockForCreate(child);
46
+ if (stripped)
47
+ childBlocks.push(stripped);
48
+ }
49
+ if (childBlocks.length > 0) {
50
+ newBlock['children'] = childBlocks;
51
+ }
52
+ }
53
+ // Remove fields not allowed in create requests
54
+ delete newBlock[type].rich_text_annotations; // not a real field, just safety
55
+ delete newBlock['id'];
56
+ delete newBlock['created_time'];
57
+ delete newBlock['last_edited_time'];
58
+ delete newBlock['created_by'];
59
+ delete newBlock['last_edited_by'];
60
+ delete newBlock['parent'];
61
+ delete newBlock['object'];
62
+ delete newBlock['has_children'];
63
+ return newBlock;
64
+ }
65
+ function extractTitleFromProperties(properties) {
66
+ for (const prop of Object.values(properties)) {
67
+ if (prop?.type === 'title') {
68
+ const texts = prop.title ?? [];
69
+ return texts.map((t) => t?.plain_text ?? '').join('');
70
+ }
71
+ }
72
+ return '';
73
+ }
74
+ function overrideTitleInProperties(properties, newTitle) {
75
+ const updated = { ...properties };
76
+ for (const [key, prop] of Object.entries(updated)) {
77
+ if (prop?.type === 'title') {
78
+ updated[key] = {
79
+ ...prop,
80
+ title: [{ type: 'text', text: { content: newTitle } }],
81
+ };
82
+ break;
83
+ }
84
+ }
85
+ return updated;
86
+ }
87
+ function sanitizePropertiesForCreate(properties) {
88
+ const result = {};
89
+ for (const [key, prop] of Object.entries(properties)) {
90
+ if (!prop || typeof prop !== 'object')
91
+ continue;
92
+ const type = prop.type;
93
+ // Only include writable property types
94
+ const writableTypes = new Set([
95
+ 'title', 'rich_text', 'number', 'select', 'multi_select',
96
+ 'date', 'people', 'files', 'checkbox', 'url', 'email',
97
+ 'phone_number', 'relation', 'status',
98
+ ]);
99
+ if (!writableTypes.has(type))
100
+ continue;
101
+ result[key] = prop;
102
+ }
103
+ return result;
104
+ }
105
+ export function register(server) {
106
+ server.tool('duplicate_page', '페이지를 복제합니다.', {
107
+ page_id: z.string().describe('복제할 페이지 ID 또는 URL'),
108
+ new_title: z
109
+ .string()
110
+ .optional()
111
+ .describe('새 페이지 제목 (기본값: "원본제목 (복사)")'),
112
+ }, async ({ page_id, new_title }) => {
113
+ try {
114
+ const notion = getNotionClient();
115
+ const parsedId = parseNotionId(page_id);
116
+ // 1. Retrieve original page
117
+ const originalPage = await notion.pages.retrieve({ page_id: parsedId });
118
+ const originalProperties = originalPage.properties ?? {};
119
+ const originalParent = originalPage.parent ?? {};
120
+ // 2. Determine title
121
+ const originalTitle = extractTitleFromProperties(originalProperties);
122
+ const finalTitle = new_title ?? `${originalTitle || 'Untitled'} (복사)`;
123
+ // 3. Override title in properties
124
+ let newProperties = sanitizePropertiesForCreate(originalProperties);
125
+ newProperties = overrideTitleInProperties(newProperties, finalTitle);
126
+ // 4. Fetch blocks (recursive, depth 2)
127
+ const blocks = await fetchBlocksRecursive(notion, parsedId, 2);
128
+ const skippedTypes = [];
129
+ const childrenBlocks = [];
130
+ for (const block of blocks) {
131
+ if (UNSUPPORTED_BLOCK_TYPES.has(block.type)) {
132
+ skippedTypes.push(block.type);
133
+ continue;
134
+ }
135
+ const stripped = stripBlockForCreate(block);
136
+ if (stripped)
137
+ childrenBlocks.push(stripped);
138
+ }
139
+ // 5. Create new page
140
+ const createParams = {
141
+ parent: originalParent,
142
+ properties: newProperties,
143
+ };
144
+ // Notion API allows max 100 children in create
145
+ if (childrenBlocks.length > 0) {
146
+ createParams['children'] = childrenBlocks.slice(0, 100);
147
+ }
148
+ const newPage = await notion.pages.create(createParams);
149
+ const newPageUrl = newPage.url ?? '';
150
+ // 6. Append remaining blocks in batches of 100
151
+ const remaining = childrenBlocks.slice(100);
152
+ let appendedExtra = 0;
153
+ for (let i = 0; i < remaining.length; i += 100) {
154
+ const batch = remaining.slice(i, i + 100);
155
+ await notion.blocks.children.append({
156
+ block_id: newPage.id,
157
+ children: batch,
158
+ });
159
+ appendedExtra += batch.length;
160
+ }
161
+ const lines = [
162
+ `## 페이지 복제 완료`,
163
+ `- 새 페이지 ID: ${newPage.id}`,
164
+ `- 새 페이지 제목: ${finalTitle}`,
165
+ ];
166
+ if (newPageUrl)
167
+ lines.push(`- URL: ${newPageUrl}`);
168
+ lines.push(`- 복사된 블록: ${childrenBlocks.length}개`);
169
+ if (appendedExtra > 0)
170
+ lines.push(` (초기 100개 + 추가 ${appendedExtra}개 append)`);
171
+ if (skippedTypes.length > 0) {
172
+ lines.push(`- 건너뛴 블록 유형 (API 미지원): ${[...new Set(skippedTypes)].join(', ')}`);
173
+ }
174
+ return {
175
+ content: [{ type: 'text', text: lines.join('\n') }],
176
+ };
177
+ }
178
+ catch (error) {
179
+ return {
180
+ content: [{ type: 'text', text: formatApiError(error) }],
181
+ isError: true,
182
+ };
183
+ }
184
+ });
185
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function register(server: McpServer): void;