@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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +36 -0
- package/dist/notion-client.d.ts +4 -0
- package/dist/notion-client.js +78 -0
- package/dist/tools/create-comment.d.ts +2 -0
- package/dist/tools/create-comment.js +54 -0
- package/dist/tools/create-database.d.ts +2 -0
- package/dist/tools/create-database.js +44 -0
- package/dist/tools/create-page.d.ts +2 -0
- package/dist/tools/create-page.js +60 -0
- package/dist/tools/duplicate-page.d.ts +2 -0
- package/dist/tools/duplicate-page.js +185 -0
- package/dist/tools/fetch-page.d.ts +2 -0
- package/dist/tools/fetch-page.js +120 -0
- package/dist/tools/get-comments.d.ts +2 -0
- package/dist/tools/get-comments.js +71 -0
- package/dist/tools/get-users.d.ts +2 -0
- package/dist/tools/get-users.js +86 -0
- package/dist/tools/move-pages.d.ts +2 -0
- package/dist/tools/move-pages.js +67 -0
- package/dist/tools/query-database.d.ts +2 -0
- package/dist/tools/query-database.js +53 -0
- package/dist/tools/search.d.ts +2 -0
- package/dist/tools/search.js +58 -0
- package/dist/tools/update-database.d.ts +2 -0
- package/dist/tools/update-database.js +71 -0
- package/dist/tools/update-page.d.ts +2 -0
- package/dist/tools/update-page.js +62 -0
- package/dist/utils/format-blocks.d.ts +1 -0
- package/dist/utils/format-blocks.js +234 -0
- package/dist/utils/format-properties.d.ts +1 -0
- package/dist/utils/format-properties.js +158 -0
- package/dist/utils/format-response.d.ts +3 -0
- package/dist/utils/format-response.js +85 -0
- 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
|
+
[](https://www.npmjs.com/package/@devheerim/notion-mcp-server)
|
|
4
|
+
[](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
|
package/dist/index.d.ts
ADDED
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,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,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,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,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,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
|
+
}
|