notion-mcp-server 2.6.1 → 2.7.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 +44 -0
- package/build/config/http.js +29 -0
- package/build/index.js +15 -4
- package/build/prompts/index.js +1 -2
- package/build/server/auth.js +36 -0
- package/build/server/http.js +202 -0
- package/build/server/index.js +49 -23
- package/build/tools/index.js +4 -8
- 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)
|
|
@@ -388,6 +389,49 @@ claude mcp add notion -s user \
|
|
|
388
389
|
|
|
389
390
|
---
|
|
390
391
|
|
|
392
|
+
## 🌐 Remote / HTTP transport
|
|
393
|
+
|
|
394
|
+
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`:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
MCP_TRANSPORT=http PORT=3000 NOTION_TOKEN=ntn_xxx npx -y notion-mcp-server
|
|
398
|
+
# -> notion-mcp-server vX.Y.Z running on http://127.0.0.1:3000/mcp
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
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.
|
|
402
|
+
|
|
403
|
+
### Configuration
|
|
404
|
+
|
|
405
|
+
| env | default | meaning |
|
|
406
|
+
| --- | --- | --- |
|
|
407
|
+
| `MCP_TRANSPORT` | `stdio` | set to `http` to enable the HTTP transport |
|
|
408
|
+
| `PORT` | `3000` | listen port (`0` = OS-assigned) |
|
|
409
|
+
| `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`) |
|
|
410
|
+
| `MCP_AUTH_TOKEN` | — | when set, every `/mcp` request must send `Authorization: Bearer <token>` |
|
|
411
|
+
| `MCP_ALLOWED_HOSTS` | localhost + bound host | comma-list for DNS-rebinding `Host` allowlist |
|
|
412
|
+
| `MCP_ALLOWED_ORIGINS` | localhost origins | comma-list for browser `Origin` allowlist |
|
|
413
|
+
|
|
414
|
+
> ⚠️ **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.
|
|
415
|
+
|
|
416
|
+
### Try it
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
# health check
|
|
420
|
+
curl http://127.0.0.1:3000/health
|
|
421
|
+
# -> {"status":"healthy","transport":"http","port":3000}
|
|
422
|
+
|
|
423
|
+
# point the MCP Inspector at it
|
|
424
|
+
npx @modelcontextprotocol/inspector --transport http --server-url http://127.0.0.1:3000/mcp
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
In Docker, set the env and publish the port:
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
docker run --rm -e NOTION_TOKEN=ntn_xxx -e MCP_TRANSPORT=http -p 3000:3000 ghcr.io/awkoy/notion-mcp-server
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
391
435
|
## 🌟 Features: what this Notion MCP server does
|
|
392
436
|
|
|
393
437
|
- **Two-tool surface** — `notion_execute` (do it) + `notion_describe` (learn the shape). The whole API is one schema deep.
|
|
@@ -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));
|
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.",
|
|
@@ -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/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 = [
|