notion-mcp-server 2.11.0 → 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/build/operations/files.js +66 -8
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
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 "${
|
|
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: {
|
|
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: {
|
|
234
|
+
file: {
|
|
235
|
+
filename: effectiveFilename,
|
|
236
|
+
data: new Blob([part], { type: effectiveType }),
|
|
237
|
+
},
|
|
180
238
|
part_number: String(partNumber),
|
|
181
239
|
};
|
|
182
240
|
try {
|