notion-mcp-server 2.6.1 → 2.8.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/README.md +48 -1
- package/build/config/http.js +29 -0
- package/build/index.js +15 -4
- package/build/operations/access.js +1 -0
- package/build/operations/data-sources.js +5 -3
- package/build/operations/index.js +1 -0
- package/build/operations/views.js +456 -0
- package/build/prompts/index.js +1 -2
- package/build/schema/blocks.js +1 -0
- package/build/server/auth.js +36 -0
- package/build/server/http.js +202 -0
- package/build/server/index.js +49 -23
- package/build/services/notion.js +1 -1
- package/build/tools/index.js +10 -13
- package/build/utils/slim.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ An agent-first **Notion MCP server** (Model Context Protocol) that connects Clau
|
|
|
26
26
|
- [Claude Code / Cursor / Claude Desktop](#claude-code--cursor--claude-desktop)
|
|
27
27
|
- [Docker / Podman / OrbStack](#docker--podman--orbstack)
|
|
28
28
|
- [Optional `NOTION_PAGE_ID`](#optional-notion_page_id)
|
|
29
|
+
- [Remote / HTTP transport](#-remote--http-transport)
|
|
29
30
|
- [Features: what this Notion MCP server does](#-features-what-this-notion-mcp-server-does)
|
|
30
31
|
- [MCP tools for Notion (`notion_execute` & `notion_describe`)](#-mcp-tools-for-notion-notion_execute--notion_describe)
|
|
31
32
|
- [`notion_execute`](#notion_execute)
|
|
@@ -246,6 +247,7 @@ blocklist). Each is a comma-separated list of **tokens**, where a token is eithe
|
|
|
246
247
|
| `blocks` | `get_block` `get_block_children` | `append_blocks` `update_block` `delete_block`† `batch_mixed_blocks`† |
|
|
247
248
|
| `databases` | `query_database` | `create_database` `update_database` |
|
|
248
249
|
| `data_sources` | `list_data_sources` `get_data_source` | `update_data_source` |
|
|
250
|
+
| `views` | `list_views` `get_view` `query_view` | `create_view` `update_view` `delete_view`† |
|
|
249
251
|
| `comments` | `list_comments` `get_comment` | `add_page_comment` `add_discussion_comment` `update_comment` `delete_comment`† |
|
|
250
252
|
| `users` | `list_users` `get_user` `get_bot_user` `get_self` | — |
|
|
251
253
|
| `files` | `list_file_uploads` `get_file_upload` | `upload_file` |
|
|
@@ -388,6 +390,49 @@ claude mcp add notion -s user \
|
|
|
388
390
|
|
|
389
391
|
---
|
|
390
392
|
|
|
393
|
+
## 🌐 Remote / HTTP transport
|
|
394
|
+
|
|
395
|
+
By default the server speaks **stdio** (the local connector path above). To run it as a remote/hosted endpoint — for web clients, networked agents, or a shared deployment — set `MCP_TRANSPORT=http`:
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
MCP_TRANSPORT=http PORT=3000 NOTION_TOKEN=ntn_xxx npx -y notion-mcp-server
|
|
399
|
+
# -> notion-mcp-server vX.Y.Z running on http://127.0.0.1:3000/mcp
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
It serves the MCP **Streamable HTTP** protocol at `POST/GET/DELETE /mcp` (stateful sessions via the `mcp-session-id` header) plus an unauthenticated `GET /health`. It's **single-tenant** — every request uses the one `NOTION_TOKEN` the process was started with.
|
|
403
|
+
|
|
404
|
+
### Configuration
|
|
405
|
+
|
|
406
|
+
| env | default | meaning |
|
|
407
|
+
| --- | --- | --- |
|
|
408
|
+
| `MCP_TRANSPORT` | `stdio` | set to `http` to enable the HTTP transport |
|
|
409
|
+
| `PORT` | `3000` | listen port (`0` = OS-assigned) |
|
|
410
|
+
| `HOST` | `127.0.0.1` | bind address. Loopback by default; set `0.0.0.0` to expose externally (do this only with `MCP_AUTH_TOKEN`) |
|
|
411
|
+
| `MCP_AUTH_TOKEN` | — | when set, every `/mcp` request must send `Authorization: Bearer <token>` |
|
|
412
|
+
| `MCP_ALLOWED_HOSTS` | localhost + bound host | comma-list for DNS-rebinding `Host` allowlist |
|
|
413
|
+
| `MCP_ALLOWED_ORIGINS` | localhost origins | comma-list for browser `Origin` allowlist |
|
|
414
|
+
|
|
415
|
+
> ⚠️ **Single-tenant means whoever reaches `/mcp` acts as your `NOTION_TOKEN`.** On loopback (the default) that's just local processes. Before binding a non-loopback `HOST`, set `MCP_AUTH_TOKEN` (the server logs a warning if you don't) and/or put it behind an authenticating reverse proxy.
|
|
416
|
+
|
|
417
|
+
### Try it
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
# health check
|
|
421
|
+
curl http://127.0.0.1:3000/health
|
|
422
|
+
# -> {"status":"healthy","transport":"http","port":3000}
|
|
423
|
+
|
|
424
|
+
# point the MCP Inspector at it
|
|
425
|
+
npx @modelcontextprotocol/inspector --transport http --server-url http://127.0.0.1:3000/mcp
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
In Docker, set the env and publish the port:
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
docker run --rm -e NOTION_TOKEN=ntn_xxx -e MCP_TRANSPORT=http -p 3000:3000 ghcr.io/awkoy/notion-mcp-server
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
391
436
|
## 🌟 Features: what this Notion MCP server does
|
|
392
437
|
|
|
393
438
|
- **Two-tool surface** — `notion_execute` (do it) + `notion_describe` (learn the shape). The whole API is one schema deep.
|
|
@@ -401,6 +446,7 @@ claude mcp add notion -s user \
|
|
|
401
446
|
- **File uploads** — `upload_file` handles single-part and multi-part (5 MB chunks) transparently; auto-detects MIME from filename; rejects `application/octet-stream`.
|
|
402
447
|
- **Opt-in auto-pagination** — pass `paginate: true` on `search_pages`, `list_comments`, or `query_database` and the server walks `next_cursor` for you (capped by `page_limit`, default 10 pages ≈ 1000 items at `page_size: 100`). Other list ops return a single Notion page with `has_more` / `next_cursor`.
|
|
403
448
|
- **Typed `where` filter shorthand** — `query_database` accepts a `where` clause like `{Status: {equals: "Done"}, AND: [...]}` with operator objects (`eq`, `ne`, `gte`, `lte`, `contains`, `starts_with`, etc.); the server compiles it to Notion filter JSON. Pass raw Notion `filter` JSON for edge cases the shorthand can't express (the two fields are mutually exclusive).
|
|
449
|
+
- **Database views** — `list_views` / `get_view` / `query_view` plus `create_view` / `update_view` / `delete_view`. `query_view` runs a view's stored filters/sorts and returns hydrated rows by default (`hydrate: false` for ids only); `create_view` / `update_view` reuse the same `where` shorthand for filters and take a raw `configuration` for type-specific layout (calendar/board/timeline/chart/map require it).
|
|
404
450
|
- **Universal MCP compatibility** — Cursor, Claude Desktop, Claude Code, Cline, Zed, Continue, anything that speaks MCP stdio.
|
|
405
451
|
|
|
406
452
|
---
|
|
@@ -462,7 +508,7 @@ Return the JSON Schema + working example for a single operation. Use this when y
|
|
|
462
508
|
{ "operation": "query_database" }
|
|
463
509
|
```
|
|
464
510
|
|
|
465
|
-
### Operations menu (
|
|
511
|
+
### Operations menu (41 ops, plus one alias)
|
|
466
512
|
|
|
467
513
|
| Area | Operations |
|
|
468
514
|
| --- | --- |
|
|
@@ -470,6 +516,7 @@ Return the JSON Schema + working example for a single operation. Use this when y
|
|
|
470
516
|
| **Blocks** | `append_blocks`, `get_block`, `get_block_children`, `update_block`, `delete_block`, `batch_mixed_blocks` |
|
|
471
517
|
| **Databases** | `create_database`, `query_database`, `update_database` |
|
|
472
518
|
| **Data sources** | `list_data_sources`, `get_data_source`, `update_data_source` |
|
|
519
|
+
| **Views** | `list_views`, `get_view`, `query_view`, `create_view`, `update_view`, `delete_view` |
|
|
473
520
|
| **Comments** | `list_comments`, `add_page_comment`, `add_discussion_comment`, `get_comment`, `update_comment`, `delete_comment` |
|
|
474
521
|
| **Users** | `list_users`, `get_user`, `get_bot_user` |
|
|
475
522
|
| **Files** | `upload_file`, `list_file_uploads`, `get_file_upload` |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_PORT = 3000;
|
|
2
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
3
|
+
function parseList(raw) {
|
|
4
|
+
if (!raw)
|
|
5
|
+
return [];
|
|
6
|
+
return raw
|
|
7
|
+
.split(",")
|
|
8
|
+
.map((s) => s.trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
/** Pure: derive the transport config from environment variables. No I/O.
|
|
12
|
+
*
|
|
13
|
+
* `allowedHosts`/`allowedOrigins` are returned as the *explicit* env lists only
|
|
14
|
+
* (empty when unset). The localhost defaults depend on the actually-bound port —
|
|
15
|
+
* which can differ from `port` when `PORT=0` — so they are filled in by startHttp
|
|
16
|
+
* after the socket is listening, not here. */
|
|
17
|
+
export function parseHttpConfig(env) {
|
|
18
|
+
const transport = (env.MCP_TRANSPORT ?? "").trim().toLowerCase() === "http" ? "http" : "stdio";
|
|
19
|
+
const portRaw = (env.PORT ?? "").trim();
|
|
20
|
+
const portNum = Number.parseInt(portRaw, 10);
|
|
21
|
+
// 0 is valid — it asks the OS for an ephemeral port. Negatives/NaN -> default.
|
|
22
|
+
const port = Number.isInteger(portNum) && portNum >= 0 ? portNum : DEFAULT_PORT;
|
|
23
|
+
const host = (env.HOST ?? "").trim() || DEFAULT_HOST;
|
|
24
|
+
const authTokenRaw = (env.MCP_AUTH_TOKEN ?? "").trim();
|
|
25
|
+
const authToken = authTokenRaw === "" ? undefined : authTokenRaw;
|
|
26
|
+
const allowedHosts = parseList(env.MCP_ALLOWED_HOSTS);
|
|
27
|
+
const allowedOrigins = parseList(env.MCP_ALLOWED_ORIGINS);
|
|
28
|
+
return { transport, port, host, authToken, allowedHosts, allowedOrigins };
|
|
29
|
+
}
|
package/build/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { initOperations } from "./operations/index.js";
|
|
3
|
+
import { parseHttpConfig } from "./config/http.js";
|
|
4
|
+
import { startStdio } from "./server/index.js";
|
|
4
5
|
async function main() {
|
|
5
6
|
try {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// Populate the global operation registry once, before any server instance
|
|
8
|
+
// is built (the HTTP transport builds one server per session).
|
|
9
|
+
await initOperations();
|
|
10
|
+
const config = parseHttpConfig(process.env);
|
|
11
|
+
if (config.transport === "http") {
|
|
12
|
+
// Lazy import so the stdio path never loads the HTTP stack.
|
|
13
|
+
const { startHttp } = await import("./server/http.js");
|
|
14
|
+
await startHttp(config);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
await startStdio();
|
|
18
|
+
}
|
|
8
19
|
}
|
|
9
20
|
catch (error) {
|
|
10
21
|
console.error("Unhandled server error:", error instanceof Error ? error.message : String(error));
|
|
@@ -58,7 +58,7 @@ const UpdateDataSourceParams = z.object({
|
|
|
58
58
|
title: z.array(z.unknown()).optional().describe("Rich text array for the data source title."),
|
|
59
59
|
properties: z.record(z.string(), DATABASE_PROPERTY_SCHEMA).optional(),
|
|
60
60
|
icon: z.unknown().optional(),
|
|
61
|
-
archived: z.boolean().optional(),
|
|
61
|
+
archived: z.boolean().optional().describe("Deprecated alias for in_trash (removed on the 2026-03-11 surface). Routed to in_trash."),
|
|
62
62
|
in_trash: z.boolean().optional(),
|
|
63
63
|
verbose: VERBOSE,
|
|
64
64
|
});
|
|
@@ -77,13 +77,15 @@ register({
|
|
|
77
77
|
},
|
|
78
78
|
handler: tryHandler(async ({ data_source_id, title, properties, icon, archived, in_trash, verbose }) => {
|
|
79
79
|
const notion = await getClient();
|
|
80
|
+
// `archived` was removed on the 2026-03-11 surface; route the legacy alias
|
|
81
|
+
// into `in_trash` so we never send a field the API rejects.
|
|
82
|
+
const trash = in_trash ?? archived;
|
|
80
83
|
const body = {
|
|
81
84
|
data_source_id,
|
|
82
85
|
...(title !== undefined ? { title } : {}),
|
|
83
86
|
...(properties !== undefined ? { properties } : {}),
|
|
84
87
|
...(icon !== undefined ? { icon } : {}),
|
|
85
|
-
...(
|
|
86
|
-
...(in_trash !== undefined ? { in_trash } : {}),
|
|
88
|
+
...(trash !== undefined ? { in_trash: trash } : {}),
|
|
87
89
|
};
|
|
88
90
|
const response = await notion.dataSources.update(asSdk(body));
|
|
89
91
|
return { ok: true, data: slimDataSource(response, verbose ?? false) };
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isFullDatabase } from "@notionhq/client";
|
|
3
|
+
import { getClient } from "../services/notion.js";
|
|
4
|
+
import { register } from "./registry.js";
|
|
5
|
+
import { tryHandler } from "../utils/handler.js";
|
|
6
|
+
import { slimView, slimPage } from "../utils/slim.js";
|
|
7
|
+
import { mapWithConcurrency } from "../dispatch/concurrency.js";
|
|
8
|
+
import { WHERE_SCHEMA, compileWhere } from "../schema/filter-dsl.js";
|
|
9
|
+
import { asSdk, } from "../utils/notion-types.js";
|
|
10
|
+
const VERBOSE = z.boolean().optional();
|
|
11
|
+
// Hydration fans out one pages.retrieve / views.retrieve per id. The dispatch
|
|
12
|
+
// rate limiter only gates the operation as a whole, not these inner calls, so
|
|
13
|
+
// keep the fan-out gentle (matches the dispatch batch default) to avoid bursting
|
|
14
|
+
// past Notion's rate limit — a 429 here would surface as a hydration miss.
|
|
15
|
+
const HYDRATE_CONCURRENCY = 3;
|
|
16
|
+
const VIEW_TYPES = [
|
|
17
|
+
"table",
|
|
18
|
+
"board",
|
|
19
|
+
"list",
|
|
20
|
+
"calendar",
|
|
21
|
+
"timeline",
|
|
22
|
+
"gallery",
|
|
23
|
+
"form",
|
|
24
|
+
"chart",
|
|
25
|
+
"map",
|
|
26
|
+
"dashboard",
|
|
27
|
+
];
|
|
28
|
+
// View types whose SDK config carries a required field — we require an explicit
|
|
29
|
+
// `configuration` so the call fails locally with a fix instead of a raw API 400.
|
|
30
|
+
const REQUIRES_CONFIG = {
|
|
31
|
+
calendar: "date_property_id (calendar views group rows by a date property)",
|
|
32
|
+
timeline: "a timeline date/range configuration",
|
|
33
|
+
board: "group_by (board views group by a property)",
|
|
34
|
+
chart: "chart axes/aggregation configuration",
|
|
35
|
+
map: "a location property configuration",
|
|
36
|
+
};
|
|
37
|
+
// Resolve the (data_source_id, database_id) pair a view is created under.
|
|
38
|
+
// Notion's createView requires BOTH in the body — the data source it targets
|
|
39
|
+
// and the database it lives in — even though the SDK types database_id as
|
|
40
|
+
// optional. We accept either input and look up the other.
|
|
41
|
+
async function resolveViewTarget(notion, database_id, data_source_id) {
|
|
42
|
+
if (!database_id && !data_source_id) {
|
|
43
|
+
return {
|
|
44
|
+
error: {
|
|
45
|
+
code: "missing_target",
|
|
46
|
+
message: "Pass data_source_id or database_id.",
|
|
47
|
+
fix: "Provide data_source_id (preferred) or database_id.",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// data_source given without its database → look up the parent database.
|
|
52
|
+
if (data_source_id && !database_id) {
|
|
53
|
+
const ds = await notion.dataSources.retrieve({ data_source_id });
|
|
54
|
+
const parent = ds.parent;
|
|
55
|
+
const dbId = parent?.type === "database_id" ? parent.database_id : undefined;
|
|
56
|
+
if (!dbId) {
|
|
57
|
+
return {
|
|
58
|
+
error: {
|
|
59
|
+
code: "no_parent_database",
|
|
60
|
+
message: `Could not resolve the parent database of data source ${data_source_id}.`,
|
|
61
|
+
fix: "Pass database_id explicitly alongside data_source_id.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { data_source_id, database_id: dbId };
|
|
66
|
+
}
|
|
67
|
+
// Both given → use as-is.
|
|
68
|
+
if (data_source_id && database_id) {
|
|
69
|
+
return { data_source_id, database_id };
|
|
70
|
+
}
|
|
71
|
+
// database given without a data source → resolve its single data source.
|
|
72
|
+
const db = await notion.databases.retrieve({ database_id: database_id });
|
|
73
|
+
const sources = isFullDatabase(db) ? db.data_sources : [];
|
|
74
|
+
if (sources.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
error: {
|
|
77
|
+
code: "no_data_source",
|
|
78
|
+
message: `Database ${database_id} has no data sources.`,
|
|
79
|
+
fix: "Pass data_source_id directly, or check the database in Notion.",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (sources.length > 1) {
|
|
84
|
+
return {
|
|
85
|
+
error: {
|
|
86
|
+
code: "multi_source_database",
|
|
87
|
+
message: `Database ${database_id} has ${sources.length} data sources.`,
|
|
88
|
+
fix: `Pass data_source_id. Available: ${sources.map((s) => s.id).join(", ")}.`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { data_source_id: sources[0].id, database_id };
|
|
93
|
+
}
|
|
94
|
+
// Compile the typed `where` DSL into a Notion filter, or pass `filter` through.
|
|
95
|
+
function compileViewFilter(where, filter) {
|
|
96
|
+
if (where !== undefined && filter !== undefined) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: {
|
|
100
|
+
code: "filter_conflict",
|
|
101
|
+
message: "Pass `where` (typed DSL) or `filter` (raw JSON), not both.",
|
|
102
|
+
fix: "Use exactly one of `where` or `filter`.",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (where !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
return { ok: true, filter: compileWhere(where) };
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: {
|
|
114
|
+
code: "where_compile_error",
|
|
115
|
+
message: err instanceof Error ? err.message : String(err),
|
|
116
|
+
fix: "Check the `where` clause shape, or fall back to raw `filter`.",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (filter !== undefined)
|
|
122
|
+
return { ok: true, filter };
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
126
|
+
// get_view
|
|
127
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
128
|
+
const GetViewParams = z.object({
|
|
129
|
+
view_id: z.string().describe("View ID to retrieve."),
|
|
130
|
+
verbose: VERBOSE,
|
|
131
|
+
});
|
|
132
|
+
register({
|
|
133
|
+
name: "get_view",
|
|
134
|
+
access: "read",
|
|
135
|
+
domain: "views",
|
|
136
|
+
description: "Retrieve a single database view's configuration (name, type, filter, sorts, layout).",
|
|
137
|
+
batchable: true,
|
|
138
|
+
schema: GetViewParams,
|
|
139
|
+
example: { view_id: "<view-id>" },
|
|
140
|
+
handler: tryHandler(async ({ view_id, verbose }) => {
|
|
141
|
+
const notion = await getClient();
|
|
142
|
+
const view = await notion.views.retrieve({ view_id });
|
|
143
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// list_views
|
|
148
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
149
|
+
const ListViewsParams = z
|
|
150
|
+
.object({
|
|
151
|
+
database_id: z.string().optional().describe("List views under this database."),
|
|
152
|
+
data_source_id: z.string().optional().describe("List views under this data source."),
|
|
153
|
+
start_cursor: z.string().optional(),
|
|
154
|
+
page_size: z.number().min(1).max(100).optional(),
|
|
155
|
+
hydrate: z
|
|
156
|
+
.boolean()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Fetch each view's name/type (default true). Set false for a cheap id-only list."),
|
|
159
|
+
verbose: VERBOSE,
|
|
160
|
+
})
|
|
161
|
+
.refine((v) => Boolean(v.database_id) || Boolean(v.data_source_id), {
|
|
162
|
+
message: "Pass database_id or data_source_id.",
|
|
163
|
+
});
|
|
164
|
+
register({
|
|
165
|
+
name: "list_views",
|
|
166
|
+
access: "read",
|
|
167
|
+
domain: "views",
|
|
168
|
+
description: "List views under a database or data source. Hydrates id-only refs to {id,name,type} by default.",
|
|
169
|
+
batchable: false,
|
|
170
|
+
schema: ListViewsParams,
|
|
171
|
+
example: { database_id: "<database-id>" },
|
|
172
|
+
handler: tryHandler(async ({ database_id, data_source_id, start_cursor, page_size, hydrate, verbose }) => {
|
|
173
|
+
const notion = await getClient();
|
|
174
|
+
const list = await notion.views.list({
|
|
175
|
+
...(database_id ? { database_id } : {}),
|
|
176
|
+
...(data_source_id ? { data_source_id } : {}),
|
|
177
|
+
...(start_cursor ? { start_cursor } : {}),
|
|
178
|
+
...(page_size ? { page_size } : {}),
|
|
179
|
+
});
|
|
180
|
+
const refs = (list.results ?? []);
|
|
181
|
+
const envelope = {
|
|
182
|
+
has_more: list.has_more ?? false,
|
|
183
|
+
next_cursor: list.next_cursor ?? null,
|
|
184
|
+
};
|
|
185
|
+
if (hydrate === false) {
|
|
186
|
+
return { ok: true, data: { results: refs.map((r) => ({ id: r.id })), ...envelope } };
|
|
187
|
+
}
|
|
188
|
+
const results = await mapWithConcurrency(refs, HYDRATE_CONCURRENCY, async (ref) => {
|
|
189
|
+
const view = await notion.views.retrieve({ view_id: ref.id });
|
|
190
|
+
return slimView(view, verbose ?? false);
|
|
191
|
+
});
|
|
192
|
+
return { ok: true, data: { results, ...envelope } };
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
196
|
+
// query_view
|
|
197
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
198
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
199
|
+
const MAX_PAGE_SIZE = 100;
|
|
200
|
+
const DEFAULT_ITEM_LIMIT = 1000;
|
|
201
|
+
const MAX_ITEM_LIMIT = 1000;
|
|
202
|
+
const QueryViewParams = z.object({
|
|
203
|
+
view_id: z.string().describe("View ID. Executes the view's stored filters/sorts server-side."),
|
|
204
|
+
page_size: z.number().min(1).max(MAX_PAGE_SIZE).optional(),
|
|
205
|
+
paginate: z.boolean().optional().describe("Walk all result pages, up to page_limit rows."),
|
|
206
|
+
page_limit: z
|
|
207
|
+
.number()
|
|
208
|
+
.min(1)
|
|
209
|
+
.max(MAX_ITEM_LIMIT)
|
|
210
|
+
.optional()
|
|
211
|
+
.describe(`Max rows when paginate:true (default ${DEFAULT_ITEM_LIMIT}).`),
|
|
212
|
+
hydrate: z
|
|
213
|
+
.boolean()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe("Fetch full row data for each result (default true). Set false to return ordered ids only."),
|
|
216
|
+
verbose: VERBOSE,
|
|
217
|
+
});
|
|
218
|
+
register({
|
|
219
|
+
name: "query_view",
|
|
220
|
+
access: "read",
|
|
221
|
+
domain: "views",
|
|
222
|
+
description: "Query a view: runs its stored filters/sorts and returns the matching rows. Hydrates row data by default.",
|
|
223
|
+
batchable: false,
|
|
224
|
+
schema: QueryViewParams,
|
|
225
|
+
example: { view_id: "<view-id>", page_size: 50 },
|
|
226
|
+
handler: tryHandler(async ({ view_id, page_size, paginate, page_limit, hydrate, verbose, }) => {
|
|
227
|
+
const notion = await getClient();
|
|
228
|
+
const pageSize = page_size ?? DEFAULT_PAGE_SIZE;
|
|
229
|
+
const doHydrate = hydrate !== false;
|
|
230
|
+
const hydrateIds = async (refs) => {
|
|
231
|
+
if (!doHydrate)
|
|
232
|
+
return refs.map((r) => ({ id: r.id }));
|
|
233
|
+
return mapWithConcurrency(refs, HYDRATE_CONCURRENCY, async (ref) => {
|
|
234
|
+
try {
|
|
235
|
+
const page = await notion.pages.retrieve({ page_id: ref.id });
|
|
236
|
+
return slimPage(page, verbose ?? false, true);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// A row deleted between query and hydrate — surface the id, keep going.
|
|
240
|
+
return { id: ref.id, _hydration_failed: true };
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
const first = (await notion.views.queries.create(asSdk({ view_id, page_size: pageSize })));
|
|
245
|
+
const queryId = first.id;
|
|
246
|
+
const totalCount = first.total_count;
|
|
247
|
+
if (!paginate) {
|
|
248
|
+
const rows = await hydrateIds(first.results ?? []);
|
|
249
|
+
const truncated = first.request_status?.type === "incomplete";
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
data: {
|
|
253
|
+
...(totalCount !== undefined ? { total_count: totalCount } : {}),
|
|
254
|
+
results: rows,
|
|
255
|
+
has_more: first.has_more ?? false,
|
|
256
|
+
next_cursor: first.next_cursor ?? null,
|
|
257
|
+
...(truncated ? { truncated: true } : {}),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// paginate: accumulate refs across pages up to page_limit, then hydrate once.
|
|
262
|
+
const limit = page_limit ?? DEFAULT_ITEM_LIMIT;
|
|
263
|
+
const refs = [];
|
|
264
|
+
let page = first;
|
|
265
|
+
let pagesWalked = 1;
|
|
266
|
+
let incomplete = first.request_status?.type === "incomplete";
|
|
267
|
+
for (const r of page.results ?? []) {
|
|
268
|
+
if (refs.length >= limit)
|
|
269
|
+
break;
|
|
270
|
+
refs.push({ id: r.id });
|
|
271
|
+
}
|
|
272
|
+
while (refs.length < limit && page.has_more && page.next_cursor) {
|
|
273
|
+
page = (await notion.views.queries.results(asSdk({
|
|
274
|
+
view_id,
|
|
275
|
+
query_id: queryId,
|
|
276
|
+
start_cursor: page.next_cursor,
|
|
277
|
+
page_size: pageSize,
|
|
278
|
+
})));
|
|
279
|
+
pagesWalked += 1;
|
|
280
|
+
if (page.request_status?.type === "incomplete")
|
|
281
|
+
incomplete = true;
|
|
282
|
+
for (const r of page.results ?? []) {
|
|
283
|
+
if (refs.length >= limit)
|
|
284
|
+
break;
|
|
285
|
+
refs.push({ id: r.id });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Best-effort cleanup of the server-side query job.
|
|
289
|
+
try {
|
|
290
|
+
await notion.views.queries.delete(asSdk({ view_id, query_id: queryId }));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore cleanup failures */
|
|
294
|
+
}
|
|
295
|
+
const rows = await hydrateIds(refs);
|
|
296
|
+
const truncated = incomplete || (Boolean(page.has_more) && refs.length >= limit);
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
data: {
|
|
300
|
+
...(totalCount !== undefined ? { total_count: totalCount } : {}),
|
|
301
|
+
results: rows,
|
|
302
|
+
truncated,
|
|
303
|
+
pages_walked: pagesWalked,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
309
|
+
// create_view
|
|
310
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
311
|
+
const CreateViewParams = z.object({
|
|
312
|
+
data_source_id: z.string().optional(),
|
|
313
|
+
database_id: z
|
|
314
|
+
.string()
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("Single-source databases are auto-resolved; multi-source require data_source_id."),
|
|
317
|
+
name: z.string().describe("View name."),
|
|
318
|
+
type: z.enum(VIEW_TYPES).describe("View type."),
|
|
319
|
+
where: WHERE_SCHEMA.optional().describe("Typed filter DSL (same shape as query_database `where`). Mutually exclusive with `filter`."),
|
|
320
|
+
filter: z.unknown().optional().describe("Raw Notion view filter JSON. Mutually exclusive with `where`."),
|
|
321
|
+
sorts: z.array(z.unknown()).optional(),
|
|
322
|
+
configuration: z
|
|
323
|
+
.unknown()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Type-specific layout/grouping config (required for calendar/board/timeline/chart/map)."),
|
|
326
|
+
verbose: VERBOSE,
|
|
327
|
+
});
|
|
328
|
+
register({
|
|
329
|
+
name: "create_view",
|
|
330
|
+
access: "write",
|
|
331
|
+
domain: "views",
|
|
332
|
+
description: "Create a database view. table/list/gallery/form need only name+type; calendar/board/timeline/chart/map require `configuration`.",
|
|
333
|
+
batchable: true,
|
|
334
|
+
schema: CreateViewParams,
|
|
335
|
+
example: {
|
|
336
|
+
data_source_id: "<data-source-id>",
|
|
337
|
+
name: "Open Tasks",
|
|
338
|
+
type: "table",
|
|
339
|
+
where: { Status: "Open" },
|
|
340
|
+
},
|
|
341
|
+
rollback: async (data) => {
|
|
342
|
+
const id = data?.id;
|
|
343
|
+
if (!id)
|
|
344
|
+
return;
|
|
345
|
+
const notion = await getClient();
|
|
346
|
+
try {
|
|
347
|
+
await notion.views.delete({ view_id: id });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
/* ignore */
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
handler: tryHandler(async ({ data_source_id, database_id, name, type, where, filter, sorts, configuration, verbose }) => {
|
|
354
|
+
if (REQUIRES_CONFIG[type] && configuration === undefined) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: {
|
|
358
|
+
code: "missing_view_config",
|
|
359
|
+
message: `A ${type} view requires \`configuration\`.`,
|
|
360
|
+
fix: `Pass \`configuration\` with ${REQUIRES_CONFIG[type]}.`,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Validate inputs (filter shape) before any network call, so bad input
|
|
365
|
+
// surfaces without a round-trip and regardless of target resolution.
|
|
366
|
+
const compiled = compileViewFilter(where, filter);
|
|
367
|
+
if (!compiled.ok)
|
|
368
|
+
return { ok: false, error: compiled.error };
|
|
369
|
+
const notion = await getClient();
|
|
370
|
+
const resolved = await resolveViewTarget(notion, database_id, data_source_id);
|
|
371
|
+
if (resolved.error)
|
|
372
|
+
return { ok: false, error: resolved.error };
|
|
373
|
+
const body = {
|
|
374
|
+
data_source_id: resolved.data_source_id,
|
|
375
|
+
// createView requires database_id in the body (SDK types it optional, but
|
|
376
|
+
// the API rejects a body without it — confirmed against the live API).
|
|
377
|
+
database_id: resolved.database_id,
|
|
378
|
+
name,
|
|
379
|
+
type,
|
|
380
|
+
...(compiled.filter !== undefined ? { filter: compiled.filter } : {}),
|
|
381
|
+
...(sorts !== undefined ? { sorts } : {}),
|
|
382
|
+
...(configuration !== undefined ? { configuration } : {}),
|
|
383
|
+
};
|
|
384
|
+
const view = await notion.views.create(asSdk(body));
|
|
385
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
389
|
+
// update_view
|
|
390
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
391
|
+
const UpdateViewParams = z.object({
|
|
392
|
+
view_id: z.string(),
|
|
393
|
+
name: z.string().optional(),
|
|
394
|
+
where: WHERE_SCHEMA.optional().describe("Replace the view filter (typed DSL). Mutually exclusive with `filter`."),
|
|
395
|
+
filter: z.unknown().optional().describe("Replace the view filter (raw JSON). Mutually exclusive with `where`."),
|
|
396
|
+
sorts: z.array(z.unknown()).optional(),
|
|
397
|
+
configuration: z.unknown().optional(),
|
|
398
|
+
clear: z
|
|
399
|
+
.array(z.enum(["filter", "sorts", "configuration"]))
|
|
400
|
+
.optional()
|
|
401
|
+
.describe("Fields to clear (set to null)."),
|
|
402
|
+
verbose: VERBOSE,
|
|
403
|
+
});
|
|
404
|
+
register({
|
|
405
|
+
name: "update_view",
|
|
406
|
+
access: "write",
|
|
407
|
+
domain: "views",
|
|
408
|
+
description: "Update a view's name/filter/sorts/configuration. Use `clear` to remove filter/sorts/configuration.",
|
|
409
|
+
batchable: true,
|
|
410
|
+
schema: UpdateViewParams,
|
|
411
|
+
example: { view_id: "<view-id>", name: "Renamed" },
|
|
412
|
+
handler: tryHandler(async ({ view_id, name, where, filter, sorts, configuration, clear, verbose }) => {
|
|
413
|
+
const compiled = compileViewFilter(where, filter);
|
|
414
|
+
if (!compiled.ok)
|
|
415
|
+
return { ok: false, error: compiled.error };
|
|
416
|
+
const toClear = new Set(clear ?? []);
|
|
417
|
+
const body = { view_id };
|
|
418
|
+
if (name !== undefined)
|
|
419
|
+
body.name = name;
|
|
420
|
+
if (compiled.filter !== undefined)
|
|
421
|
+
body.filter = compiled.filter;
|
|
422
|
+
if (sorts !== undefined)
|
|
423
|
+
body.sorts = sorts;
|
|
424
|
+
if (configuration !== undefined)
|
|
425
|
+
body.configuration = configuration;
|
|
426
|
+
if (toClear.has("filter"))
|
|
427
|
+
body.filter = null;
|
|
428
|
+
if (toClear.has("sorts"))
|
|
429
|
+
body.sorts = null;
|
|
430
|
+
if (toClear.has("configuration"))
|
|
431
|
+
body.configuration = null;
|
|
432
|
+
const notion = await getClient();
|
|
433
|
+
const view = await notion.views.update(asSdk(body));
|
|
434
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
438
|
+
// delete_view
|
|
439
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
440
|
+
const DeleteViewParams = z.object({ view_id: z.string() });
|
|
441
|
+
register({
|
|
442
|
+
name: "delete_view",
|
|
443
|
+
access: "write",
|
|
444
|
+
domain: "views",
|
|
445
|
+
destructive: true,
|
|
446
|
+
description: "Delete a database view. Irreversible. Honors NOTION_READ_ONLY and the operation allow/block lists.",
|
|
447
|
+
batchable: true,
|
|
448
|
+
schema: DeleteViewParams,
|
|
449
|
+
example: { view_id: "<view-id>" },
|
|
450
|
+
handler: tryHandler(async ({ view_id }) => {
|
|
451
|
+
const notion = await getClient();
|
|
452
|
+
const result = await notion.views.delete({ view_id });
|
|
453
|
+
const r = result;
|
|
454
|
+
return { ok: true, data: { id: r.id ?? view_id, deleted: r.deleted ?? true } };
|
|
455
|
+
}),
|
|
456
|
+
});
|
package/build/prompts/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { server } from "../server/index.js";
|
|
3
2
|
function userMessage(text) {
|
|
4
3
|
return {
|
|
5
4
|
messages: [
|
|
@@ -10,7 +9,7 @@ function userMessage(text) {
|
|
|
10
9
|
],
|
|
11
10
|
};
|
|
12
11
|
}
|
|
13
|
-
export function registerAllPrompts() {
|
|
12
|
+
export function registerAllPrompts(server) {
|
|
14
13
|
server.registerPrompt("create_task", {
|
|
15
14
|
title: "Create Notion task",
|
|
16
15
|
description: "Create a new task page in Notion with optional status and due date.",
|
package/build/schema/blocks.js
CHANGED
|
@@ -21,6 +21,7 @@ export const BASE_BLOCK_REQUEST_SCHEMA = z.object({
|
|
|
21
21
|
.optional()
|
|
22
22
|
.describe("Whether block has child blocks"),
|
|
23
23
|
archived: z.boolean().optional().describe("Whether block is archived"),
|
|
24
|
+
in_trash: z.boolean().optional().describe("Whether block is in trash (2026-03-11 surface)"),
|
|
24
25
|
});
|
|
25
26
|
export const TEXT_BLOCK_BASE_REQUEST_SCHEMA = z.object({
|
|
26
27
|
rich_text: z
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
function bearerToken(headers) {
|
|
3
|
+
const raw = headers["authorization"] ?? headers["Authorization"];
|
|
4
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
const parts = value.trim().split(/\s+/);
|
|
8
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer")
|
|
9
|
+
return null;
|
|
10
|
+
return parts[1];
|
|
11
|
+
}
|
|
12
|
+
function constantTimeEqual(a, b) {
|
|
13
|
+
// timingSafeEqual requires equal-length buffers; differing lengths are a
|
|
14
|
+
// mismatch by definition (the length leak is acceptable and standard).
|
|
15
|
+
if (a.length !== b.length)
|
|
16
|
+
return false;
|
|
17
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Gate an HTTP request against the optional bearer token.
|
|
21
|
+
* - No token configured -> open (ok).
|
|
22
|
+
* - Token configured, header missing/malformed -> 401.
|
|
23
|
+
* - Token configured, value mismatch -> 403.
|
|
24
|
+
*/
|
|
25
|
+
export function checkAuth(headers, expectedToken) {
|
|
26
|
+
if (!expectedToken)
|
|
27
|
+
return { ok: true };
|
|
28
|
+
const provided = bearerToken(headers);
|
|
29
|
+
if (provided === null) {
|
|
30
|
+
return { ok: false, status: 401, message: "Unauthorized: missing bearer token" };
|
|
31
|
+
}
|
|
32
|
+
if (!constantTimeEqual(provided, expectedToken)) {
|
|
33
|
+
return { ok: false, status: 403, message: "Forbidden: invalid bearer token" };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { CONFIG } from "../config/index.js";
|
|
6
|
+
import { createServer, logAccessSummary, verifyNotionAuth } from "./index.js";
|
|
7
|
+
import { checkAuth } from "./auth.js";
|
|
8
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
9
|
+
class BodyError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function readJsonBody(req) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let size = 0;
|
|
19
|
+
const chunks = [];
|
|
20
|
+
req.on("data", (chunk) => {
|
|
21
|
+
size += chunk.length;
|
|
22
|
+
if (size > MAX_BODY_BYTES) {
|
|
23
|
+
reject(new BodyError(413, "Request body too large"));
|
|
24
|
+
req.destroy();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
chunks.push(chunk);
|
|
28
|
+
});
|
|
29
|
+
req.on("end", () => {
|
|
30
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
31
|
+
if (raw.trim() === "")
|
|
32
|
+
return resolve(undefined);
|
|
33
|
+
try {
|
|
34
|
+
resolve(JSON.parse(raw));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
reject(new BodyError(400, "Invalid JSON body"));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
req.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Discard any remaining request body and resolve once it's fully consumed. */
|
|
44
|
+
function drain(req) {
|
|
45
|
+
if (req.readableEnded || req.destroyed)
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
req.on("end", resolve);
|
|
49
|
+
req.on("close", resolve);
|
|
50
|
+
req.on("error", () => resolve());
|
|
51
|
+
req.resume();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function sendJsonRpcError(req, res, status, code, message) {
|
|
55
|
+
// Fully drain the request body before responding. Ending the response while the
|
|
56
|
+
// client is still streaming the body resets the socket (ECONNRESET) and the client
|
|
57
|
+
// never sees our status — so we wait for the upload to finish first.
|
|
58
|
+
await drain(req);
|
|
59
|
+
if (res.headersSent || res.writableEnded || res.destroyed)
|
|
60
|
+
return;
|
|
61
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null });
|
|
62
|
+
// Connection: close — we rejected this request without reusing the socket; some
|
|
63
|
+
// keep-alive clients (Node's undici fetch) otherwise RST when they get an early
|
|
64
|
+
// response while still uploading the body. Explicit length avoids chunked framing.
|
|
65
|
+
res.writeHead(status, {
|
|
66
|
+
"content-type": "application/json",
|
|
67
|
+
"content-length": Buffer.byteLength(payload),
|
|
68
|
+
connection: "close",
|
|
69
|
+
});
|
|
70
|
+
res.end(payload);
|
|
71
|
+
}
|
|
72
|
+
function isLoopbackHost(host) {
|
|
73
|
+
return (host === "127.0.0.1" ||
|
|
74
|
+
host === "localhost" ||
|
|
75
|
+
host === "::1" ||
|
|
76
|
+
host === "[::1]");
|
|
77
|
+
}
|
|
78
|
+
/** Localhost Host-header allowlist for DNS-rebinding protection, using the
|
|
79
|
+
* actually-bound port (handles PORT=0). Used when MCP_ALLOWED_HOSTS is unset. */
|
|
80
|
+
function defaultAllowedHosts(host, port) {
|
|
81
|
+
const names = new Set(["127.0.0.1", "localhost", "[::1]", host]);
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const n of names)
|
|
84
|
+
out.push(n, `${n}:${port}`);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function defaultAllowedOrigins(port) {
|
|
88
|
+
return ["127.0.0.1", "localhost", "[::1]"].map((h) => `http://${h}:${port}`);
|
|
89
|
+
}
|
|
90
|
+
export async function startHttp(config) {
|
|
91
|
+
// One transport per session; the connected server instance lives behind it.
|
|
92
|
+
const transports = {};
|
|
93
|
+
const httpServer = http.createServer((req, res) => {
|
|
94
|
+
void handle(req, res).catch(async (err) => {
|
|
95
|
+
console.error("HTTP handler error:", err);
|
|
96
|
+
await sendJsonRpcError(req, res, 500, -32603, "Internal server error");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
// Bind first so we know the real port (PORT=0 -> OS-assigned) before building
|
|
100
|
+
// the DNS-rebinding allowlist. Reject (don't hang) on a bind failure like EADDRINUSE.
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
httpServer.once("error", reject);
|
|
103
|
+
httpServer.listen(config.port, config.host, () => {
|
|
104
|
+
httpServer.removeListener("error", reject);
|
|
105
|
+
resolve();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
const addr = httpServer.address();
|
|
109
|
+
const port = typeof addr === "object" && addr ? addr.port : config.port;
|
|
110
|
+
const allowedHosts = config.allowedHosts.length > 0
|
|
111
|
+
? config.allowedHosts
|
|
112
|
+
: defaultAllowedHosts(config.host, port);
|
|
113
|
+
const allowedOrigins = config.allowedOrigins.length > 0
|
|
114
|
+
? config.allowedOrigins
|
|
115
|
+
: defaultAllowedOrigins(port);
|
|
116
|
+
async function handle(req, res) {
|
|
117
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
118
|
+
const pathname = url.pathname;
|
|
119
|
+
// Liveness probe — no auth, no session.
|
|
120
|
+
if (req.method === "GET" && pathname === "/health") {
|
|
121
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
122
|
+
res.end(JSON.stringify({ status: "healthy", transport: "http", port }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (pathname !== "/mcp") {
|
|
126
|
+
await sendJsonRpcError(req, res, 404, -32601, "Not found");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const auth = checkAuth(req.headers, config.authToken);
|
|
130
|
+
if (!auth.ok) {
|
|
131
|
+
await sendJsonRpcError(req, res, auth.status, auth.status === 401 ? -32001 : -32002, auth.message);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
135
|
+
if (req.method === "POST") {
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
body = await readJsonBody(req);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
const status = e instanceof BodyError ? e.status : 400;
|
|
142
|
+
await sendJsonRpcError(req, res, status, -32700, e instanceof Error ? e.message : "Parse error");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let transport = sessionId ? transports[sessionId] : undefined;
|
|
146
|
+
if (!transport) {
|
|
147
|
+
if (!sessionId && isInitializeRequest(body)) {
|
|
148
|
+
transport = new StreamableHTTPServerTransport({
|
|
149
|
+
sessionIdGenerator: () => randomUUID(),
|
|
150
|
+
onsessioninitialized: (id) => {
|
|
151
|
+
transports[id] = transport;
|
|
152
|
+
},
|
|
153
|
+
enableDnsRebindingProtection: true,
|
|
154
|
+
allowedHosts,
|
|
155
|
+
allowedOrigins,
|
|
156
|
+
});
|
|
157
|
+
transport.onclose = () => {
|
|
158
|
+
if (transport.sessionId)
|
|
159
|
+
delete transports[transport.sessionId];
|
|
160
|
+
};
|
|
161
|
+
const server = createServer();
|
|
162
|
+
await server.connect(transport);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
await sendJsonRpcError(req, res, 400, -32000, "Bad Request: no valid session ID");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await transport.handleRequest(req, res, body);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
173
|
+
const transport = sessionId ? transports[sessionId] : undefined;
|
|
174
|
+
if (!transport) {
|
|
175
|
+
await sendJsonRpcError(req, res, 400, -32000, "Bad Request: invalid or missing session ID");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await transport.handleRequest(req, res);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await sendJsonRpcError(req, res, 405, -32601, "Method not allowed");
|
|
182
|
+
}
|
|
183
|
+
console.error(`${CONFIG.serverName} v${CONFIG.serverVersion} running on http://${config.host}:${port}/mcp`);
|
|
184
|
+
if (!config.authToken && !isLoopbackHost(config.host)) {
|
|
185
|
+
console.error("WARNING: HTTP endpoint bound to a non-loopback host without MCP_AUTH_TOKEN — anyone who can reach it acts as your NOTION_TOKEN. Set MCP_AUTH_TOKEN.");
|
|
186
|
+
}
|
|
187
|
+
logAccessSummary();
|
|
188
|
+
verifyNotionAuth();
|
|
189
|
+
const close = () => new Promise((resolve, reject) => {
|
|
190
|
+
for (const id of Object.keys(transports)) {
|
|
191
|
+
try {
|
|
192
|
+
transports[id].close();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// best-effort
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
httpServer.closeAllConnections?.();
|
|
199
|
+
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
200
|
+
});
|
|
201
|
+
return { port, close };
|
|
202
|
+
}
|
package/build/server/index.js
CHANGED
|
@@ -2,36 +2,62 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CONFIG } from "../config/index.js";
|
|
4
4
|
import { getClient } from "../services/notion.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
import { registerAllTools } from "../tools/index.js";
|
|
6
|
+
import { accessSummary } from "../operations/access.js";
|
|
7
|
+
/**
|
|
8
|
+
* Build a fresh, fully-registered MCP server instance.
|
|
9
|
+
*
|
|
10
|
+
* A factory (not a module singleton) because the Streamable HTTP transport needs
|
|
11
|
+
* one server per session. `initOperations()` must have run before this is called —
|
|
12
|
+
* it populates the global operation registry that the tools read from; this factory
|
|
13
|
+
* only wires the server's tools/resources/prompts and never re-registers operations.
|
|
14
|
+
*/
|
|
15
|
+
export function createServer() {
|
|
16
|
+
const server = new McpServer({
|
|
17
|
+
name: CONFIG.serverName,
|
|
18
|
+
title: CONFIG.serverTitle,
|
|
19
|
+
version: CONFIG.serverVersion,
|
|
20
|
+
websiteUrl: CONFIG.serverUrl,
|
|
21
|
+
}, {
|
|
22
|
+
capabilities: {
|
|
23
|
+
tools: {},
|
|
24
|
+
prompts: {},
|
|
25
|
+
resources: {},
|
|
26
|
+
},
|
|
27
|
+
instructions: `
|
|
16
28
|
MCP server for Notion.
|
|
17
29
|
It is used to create, update and delete Notion entities.
|
|
18
30
|
`,
|
|
19
|
-
});
|
|
20
|
-
|
|
31
|
+
});
|
|
32
|
+
registerAllTools(server);
|
|
33
|
+
return server;
|
|
34
|
+
}
|
|
35
|
+
/** Log the operation access summary once at startup (not per session). */
|
|
36
|
+
export function logAccessSummary() {
|
|
37
|
+
const s = accessSummary();
|
|
38
|
+
console.error(`Operation access: ${s.enabled}/${s.total} enabled (allow=${s.allow}; block=${s.block}${s.readOnly ? "; read-only" : ""})`);
|
|
39
|
+
}
|
|
40
|
+
/** Fire-and-forget Notion auth probe; logs who we connected as, never throws. */
|
|
41
|
+
export function verifyNotionAuth() {
|
|
42
|
+
getClient()
|
|
43
|
+
.then((c) => c.users.me({}))
|
|
44
|
+
.then((me) => {
|
|
45
|
+
const who = "name" in me && me.name ? me.name : me.id;
|
|
46
|
+
console.error(`Notion auth OK — connected as ${who} (NOTION_TOKEN)`);
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
50
|
+
console.error(`Notion auth check failed (server still running): ${msg}`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export async function startStdio() {
|
|
21
54
|
try {
|
|
55
|
+
const server = createServer();
|
|
22
56
|
const transport = new StdioServerTransport();
|
|
23
57
|
await server.connect(transport);
|
|
24
58
|
console.error(`${CONFIG.serverName} v${CONFIG.serverVersion} running on stdio`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.then((me) => {
|
|
28
|
-
const who = "name" in me && me.name ? me.name : me.id;
|
|
29
|
-
console.error(`Notion auth OK — connected as ${who} (NOTION_TOKEN)`);
|
|
30
|
-
})
|
|
31
|
-
.catch((err) => {
|
|
32
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
-
console.error(`Notion auth check failed (server still running): ${msg}`);
|
|
34
|
-
});
|
|
59
|
+
logAccessSummary();
|
|
60
|
+
verifyNotionAuth();
|
|
35
61
|
}
|
|
36
62
|
catch (error) {
|
|
37
63
|
console.error("Server initialization error:", error instanceof Error ? error.message : String(error));
|
package/build/services/notion.js
CHANGED
package/build/tools/index.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { server } from "../server/index.js";
|
|
4
3
|
import { readNotionResource } from "./resources.js";
|
|
5
|
-
import {
|
|
6
|
-
import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, enabledOperations,
|
|
4
|
+
import { getOperation } from "../operations/index.js";
|
|
5
|
+
import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, enabledOperations, } from "../operations/access.js";
|
|
7
6
|
import { dispatch } from "../dispatch/index.js";
|
|
8
7
|
import { emitJsonSchema } from "../schema/emit.js";
|
|
9
8
|
import { registerAllPrompts } from "../prompts/index.js";
|
|
@@ -38,8 +37,7 @@ If the payload is malformed, the error response includes the full schema + a wor
|
|
|
38
37
|
|
|
39
38
|
Most responses are slimmed by default. Pass verbose:true inside payload (single) or per-item (batch) to get the raw Notion SDK response.`;
|
|
40
39
|
const DESCRIBE_DESCRIPTION = `Return the JSON Schema and a working example for one operation. Use this BEFORE notion_execute when the payload shape is non-trivial (query filters, structured block trees, database property definitions). For simple ops, just call notion_execute — its errors carry the schema.`;
|
|
41
|
-
export
|
|
42
|
-
await initOperations();
|
|
40
|
+
export function registerAllTools(server) {
|
|
43
41
|
server.registerTool("notion_execute", {
|
|
44
42
|
title: "Notion Execute",
|
|
45
43
|
description: EXECUTE_DESCRIPTION,
|
|
@@ -126,9 +124,7 @@ export async function registerAllTools() {
|
|
|
126
124
|
const { mimeType, text } = await readNotionResource("database", firstVar(variables.dataSourceId));
|
|
127
125
|
return { contents: [{ uri: uri.href, mimeType, text }] };
|
|
128
126
|
});
|
|
129
|
-
registerAllPrompts();
|
|
130
|
-
const s = accessSummary();
|
|
131
|
-
console.error(`Operation access: ${s.enabled}/${s.total} enabled (allow=${s.allow}; block=${s.block}${s.readOnly ? "; read-only" : ""})`);
|
|
127
|
+
registerAllPrompts(server);
|
|
132
128
|
}
|
|
133
129
|
function renderOperationsIndex() {
|
|
134
130
|
const lines = [
|
|
@@ -142,12 +138,13 @@ function renderOperationsIndex() {
|
|
|
142
138
|
for (const def of enabledOperations()) {
|
|
143
139
|
lines.push(`| \`${def.name}\` | ${def.batchable ? "yes" : "no"} | ${def.description} |`);
|
|
144
140
|
}
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
141
|
+
// Document the WHERE DSL when any operation that accepts it is enabled.
|
|
142
|
+
// query_database, create_view, and update_view all take the same `where`.
|
|
143
|
+
const whereOps = ["query_database", "create_view", "update_view"].filter((op) => isOperationAllowed(op));
|
|
144
|
+
if (whereOps.length === 0) {
|
|
148
145
|
return lines.join("\n");
|
|
149
146
|
}
|
|
150
|
-
lines.push("", "##
|
|
151
|
-
lines.push(
|
|
147
|
+
lines.push("", "## WHERE filter DSL", "");
|
|
148
|
+
lines.push(`The same \`where\` DSL is accepted by ${whereOps.map((o) => `\`${o}\``).join(", ")}. It is a compact shorthand that compiles to the Notion filter object. AND-by-default at the top level; nest \`and\`/\`or\`/\`not\` (case-insensitive — \`AND\`/\`OR\`/\`NOT\` also work) for boolean groups, prefix scalars with \`__type\` to force the property type, or fall back to raw \`filter\` for anything the DSL can't express.`, "", "Common shapes:", "", "```jsonc", "// Single equality (property type inferred from value, or from data source schema via __type):", "{ \"where\": { \"Status\": \"Open\" } }", "", "// AND of multiple properties (top-level keys are implicit AND):", "{ \"where\": { \"Status\": \"Done\", \"Done\": true } }", "", "// Explicit operator on one property:", "{ \"where\": { \"Priority\": { \"gte\": 3 } } }", "", "// Boolean groups (lowercase or uppercase — both work):", "{ \"where\": { \"or\": [ { \"Status\": \"Open\" }, { \"Status\": \"In progress\" } ] } }", "{ \"where\": { \"and\": [ { \"Status\": \"Done\" }, { \"Priority\": { \"gte\": 5 } } ] } }", "{ \"where\": { \"not\": { \"Status\": \"Done\" } } }", "", "// in / notIn fan out to OR / AND of equals:", "{ \"where\": { \"Status\": { \"in\": [\"Open\", \"In progress\"] } } }", "", "// Force property type when value shape is ambiguous (e.g. a string that's actually a multi_select tag):", "{ \"where\": { \"Tags\": { \"__type\": \"multi_select\", \"eq\": \"alpha\" } } }", "{ \"where\": { \"Created\": { \"__type\": \"date\", \"on_or_after\": \"2026-01-01\" } } }", "```", "", "If a column is literally named `and`/`or`/`not`, wrap it as an operator object (e.g. `{ \"and\": { \"__type\": \"select\", \"eq\": \"x\" } }`) so it isn't parsed as a combinator. For anything the DSL can't express, pass `filter` (raw Notion filter object) instead of `where`.");
|
|
152
149
|
return lines.join("\n");
|
|
153
150
|
}
|
package/build/utils/slim.js
CHANGED
|
@@ -205,6 +205,21 @@ export function slimDataSource(ds, verbose = false) {
|
|
|
205
205
|
...(ds.in_trash ? { in_trash: true } : {}),
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
|
+
// View objects are loosely typed on the SDK surface, so accept `unknown` and
|
|
209
|
+
// narrow defensively. Default keeps the high-signal fields (id/name/type and
|
|
210
|
+
// any filter/sorts); the bulky type-specific `configuration` is verbose-only.
|
|
211
|
+
export function slimView(view, verbose = false) {
|
|
212
|
+
if (verbose)
|
|
213
|
+
return view;
|
|
214
|
+
const v = (view ?? {});
|
|
215
|
+
return {
|
|
216
|
+
id: v.id,
|
|
217
|
+
...(v.name !== undefined ? { name: v.name } : {}),
|
|
218
|
+
...(v.type !== undefined ? { type: v.type } : {}),
|
|
219
|
+
...(v.filter ? { filter: v.filter } : {}),
|
|
220
|
+
...(Array.isArray(v.sorts) && v.sorts.length ? { sorts: v.sorts } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
208
223
|
export function slimItem(item, verbose = false, includeProperties = false) {
|
|
209
224
|
if (verbose)
|
|
210
225
|
return item;
|