notion-mcp-server 2.10.1 → 2.12.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 CHANGED
@@ -12,7 +12,7 @@ Give your AI full read/write access to Notion with **one token and one paste**.
12
12
  Three reasons it exists when Notion ships its own MCP:
13
13
 
14
14
  - **Built for agents, not humans-in-the-loop.** Notion's hosted MCP is OAuth-only — it cannot run headless. This server authenticates with a token, so it works in **CI, cron jobs, background agents, and self-hosted deployments** where nobody can click "Authorize".
15
- - **~90% less context overhead.** Two MCP tools (`notion_execute` + `notion_describe`) dispatch **43 operations**, instead of one tool schema per endpoint flooding your agent's context.
15
+ - **97% smaller tool footprint at connection.** Two MCP tools (**422 tokens**) instead of one schema per endpoint the official open-source server loads **17,163 tokens** of tool schemas before you do anything. Operation schemas load on demand via `notion_describe`, so even a typical multi-operation task stays 85–95% lighter. [Measured, reproducible →](./benchmarks)
16
16
  - **The operational stuff is built in.** Batched mutations with atomic rollback, idempotency keys, automatic retry on rate limits, slim token-efficient responses, full markdown round-trip, and self-healing validation errors that let the model fix its own bad payloads in one turn.
17
17
 
18
18
  <a href="https://glama.ai/mcp/servers/zrh07hteaa">
@@ -123,7 +123,7 @@ If you just want to chat with your Notion in claude.ai's web UI, use Notion's ho
123
123
 
124
124
  | Capability | Official Notion MCP (open source) | **This server** |
125
125
  | --- | --- | --- |
126
- | **Tool surface** | ~24 tools (one per endpoint) loaded into context | **2 tools** the LLM loads ~90% less schema |
126
+ | **Tool surface** | 24 tools (one per endpoint), 17,163 tokens loaded into context | **2 tools**, 422 tokens [97% less schema at connection](./benchmarks) |
127
127
  | **Operations covered** | ~24 endpoints | **43 operations** (plus a `trash_page` alias) across pages, blocks, databases, data sources, views, templates, comments, users, files |
128
128
  | **Batch mutations** | Not documented | ✅ Universal `{ items: [...] }` envelope; up to **10 in parallel** |
129
129
  | **Atomic batches + rollback** | Not documented | ✅ `atomic: true` aborts on first failure, best-effort archives entities created earlier |
@@ -1,4 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename } from "node:path";
2
5
  import { getClient } from "../services/notion.js";
3
6
  import { register } from "./registry.js";
4
7
  import { tryHandler } from "../utils/handler.js";
@@ -16,7 +19,23 @@ const SourceSchema = z.discriminatedUnion("type", [
16
19
  type: z.literal("url"),
17
20
  url: z.url().describe("Public URL to fetch the file bytes from."),
18
21
  }),
22
+ z.object({
23
+ type: z.literal("path"),
24
+ path: z
25
+ .string()
26
+ .describe("Local filesystem path (absolute, or ~-relative). The server reads the file directly — bytes never pass through the tool call, so this is the fastest, cheapest source for files on the same machine as the server."),
27
+ }),
19
28
  ]);
29
+ // Expand a leading ~ or ~/ to the current user's home directory. Node's fs
30
+ // does not do this itself, and ~-relative paths are the common shape a caller
31
+ // hands to a local stdio server.
32
+ function expandHome(p) {
33
+ if (p === "~")
34
+ return homedir();
35
+ if (p.startsWith("~/"))
36
+ return homedir() + p.slice(1);
37
+ return p;
38
+ }
20
39
  // Returns Uint8Array<ArrayBuffer> — the DOM Blob constructor's BlobPart type
21
40
  // rejects Uint8Array<ArrayBufferLike> under newer @types/node (it widens to
22
41
  // include SharedArrayBuffer). Allocating fresh guarantees the concrete type.
@@ -27,6 +46,12 @@ async function resolveBytes(source) {
27
46
  out.set(buf);
28
47
  return out;
29
48
  }
49
+ if (source.type === "path") {
50
+ const buf = await readFile(expandHome(source.path));
51
+ const out = new Uint8Array(buf.byteLength);
52
+ out.set(buf);
53
+ return out;
54
+ }
30
55
  const res = await fetch(source.url);
31
56
  if (!res.ok) {
32
57
  throw new Error(`Failed to fetch ${source.url}: ${res.status} ${res.statusText}`);
@@ -97,8 +122,17 @@ const EXTENSION_TO_MIME = {
97
122
  // Documents
98
123
  csv: "text/csv",
99
124
  json: "application/json",
125
+ md: "text/markdown",
126
+ markdown: "text/markdown",
100
127
  pdf: "application/pdf",
101
128
  txt: "text/plain",
129
+ // Microsoft Office
130
+ doc: "application/msword",
131
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
132
+ ppt: "application/vnd.ms-powerpoint",
133
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
134
+ xls: "application/vnd.ms-excel",
135
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
102
136
  };
103
137
  function inferContentType(filename) {
104
138
  const dot = filename.lastIndexOf(".");
@@ -115,7 +149,10 @@ const UploadFileParams = z.object({
115
149
  .enum(["single", "multi"])
116
150
  .optional()
117
151
  .describe("'single' (default) = one create+send call. 'multi' = chunk into 5MB parts then complete."),
118
- filename: z.string(),
152
+ filename: z
153
+ .string()
154
+ .optional()
155
+ .describe("Required for base64 and url sources. Optional for a path source — defaults to the file's basename."),
119
156
  content_type: z.string().optional(),
120
157
  source: SourceSchema,
121
158
  });
@@ -123,7 +160,7 @@ register({
123
160
  name: "upload_file",
124
161
  access: "write",
125
162
  domain: "files",
126
- description: "Upload a file via Notion's file_uploads API. Handles single-part (one create + one send) and multi-part (create + N sends + complete) transparently.\n\nSource shapes:\n • Base64 bytes: `source: { type: \"base64\", data: \"<b64 string>\" }`\n • Public URL: `source: { type: \"url\", url: \"https://example.com/file.pdf\" }` (the server fetches it server-side).\n\n`mode` defaults to \"single\"; only pass \"multi\" for files larger than ~5MB.",
163
+ description: "Upload a file via Notion's file_uploads API. Handles single-part (one create + one send) and multi-part (create + N sends + complete) transparently.\n\nSource shapes:\n • Local path: `source: { type: \"path\", path: \"/abs/or/~/file.pdf\" }` (server reads the file directly — preferred for local files; filename is derived from the path if omitted).\n • Base64 bytes: `source: { type: \"base64\", data: \"<b64 string>\" }`\n • Public URL: `source: { type: \"url\", url: \"https://example.com/file.pdf\" }` (the server fetches it server-side).\n\n`mode` defaults to \"single\"; only pass \"multi\" for files larger than ~5MB.",
127
164
  batchable: false,
128
165
  schema: UploadFileParams,
129
166
  example: {
@@ -133,19 +170,34 @@ register({
133
170
  },
134
171
  handler: tryHandler(async ({ mode, filename, content_type, source }) => {
135
172
  const effectiveMode = mode ?? "single";
173
+ // A path source carries its own name; fall back to the basename when the
174
+ // caller doesn't pass filename explicitly. base64/url have no name to
175
+ // derive, so filename stays required there.
176
+ const effectiveFilename = filename ??
177
+ (source.type === "path" ? basename(expandHome(source.path)) : undefined);
178
+ if (!effectiveFilename) {
179
+ return {
180
+ ok: false,
181
+ error: {
182
+ code: "validation_error",
183
+ message: "filename is required for base64 and url sources (there is no name to derive).",
184
+ fix: 'Pass `filename` (e.g. "report.pdf"), or use a path source to derive it from the path.',
185
+ },
186
+ };
187
+ }
136
188
  const notion = await getClient();
137
189
  const bytes = await resolveBytes(source);
138
190
  // Notion rejects send() when the Blob's MIME doesn't match the
139
191
  // content_type stored at create(), and rejects application/octet-stream
140
192
  // outright. Resolve a single MIME for both sides: caller's content_type
141
193
  // wins, else infer from the filename extension.
142
- const effectiveType = content_type ?? inferContentType(filename);
194
+ const effectiveType = content_type ?? inferContentType(effectiveFilename);
143
195
  if (!effectiveType) {
144
196
  return {
145
197
  ok: false,
146
198
  error: {
147
199
  code: "validation_error",
148
- message: `Could not infer content_type from filename "${filename}". Notion's File Upload API rejects application/octet-stream and only accepts a fixed allowlist of MIME types.`,
200
+ message: `Could not infer content_type from filename "${effectiveFilename}". Notion's File Upload API rejects application/octet-stream and only accepts a fixed allowlist of MIME types.`,
149
201
  fix: "Pass `content_type` explicitly (e.g. \"application/pdf\", \"image/png\", \"text/plain\"). See https://developers.notion.com/docs/working-with-files-and-media for the full list.",
150
202
  },
151
203
  };
@@ -153,13 +205,16 @@ register({
153
205
  if (effectiveMode === "single") {
154
206
  const createBody = {
155
207
  mode: "single_part",
156
- filename,
208
+ filename: effectiveFilename,
157
209
  content_type: effectiveType,
158
210
  };
159
211
  const created = await notion.fileUploads.create(createBody);
160
212
  const sendBody = {
161
213
  file_upload_id: created.id,
162
- file: { filename, data: new Blob([bytes], { type: effectiveType }) },
214
+ file: {
215
+ filename: effectiveFilename,
216
+ data: new Blob([bytes], { type: effectiveType }),
217
+ },
163
218
  };
164
219
  const sent = await notion.fileUploads.send(sendBody);
165
220
  return { ok: true, data: slimFileUpload(sent) };
@@ -167,7 +222,7 @@ register({
167
222
  const parts = splitIntoParts(bytes);
168
223
  const createBody = {
169
224
  mode: "multi_part",
170
- filename,
225
+ filename: effectiveFilename,
171
226
  content_type: effectiveType,
172
227
  number_of_parts: parts.length,
173
228
  };
@@ -176,7 +231,10 @@ register({
176
231
  const partNumber = index + 1;
177
232
  const sendBody = {
178
233
  file_upload_id: created.id,
179
- file: { filename, data: new Blob([part], { type: effectiveType }) },
234
+ file: {
235
+ filename: effectiveFilename,
236
+ data: new Blob([part], { type: effectiveType }),
237
+ },
180
238
  part_number: String(partNumber),
181
239
  };
182
240
  try {
@@ -125,9 +125,9 @@ export const RELATION_DB_PROPERTY_SCHEMA = z.object({
125
125
  type: z.literal("relation").describe("Relation property type"),
126
126
  relation: z
127
127
  .object({
128
- database_id: z
128
+ data_source_id: z
129
129
  .string()
130
- .describe("The ID of the database this relation refers to"),
130
+ .describe("The ID of the data source this relation refers to"),
131
131
  synced_property_name: z
132
132
  .string()
133
133
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notion-mcp-server",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "mcpName": "io.github.awkoy/notion-mcp-server",
5
5
  "type": "module",
6
6
  "bin": {