@alfe.ai/notion-mcp 0.3.16 → 0.3.18

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/dist/server.js CHANGED
@@ -1,287 +1,797 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
7
+ import { resolveConfig } from "@alfe.ai/config";
8
+ import { assertPatternA } from "@alfe.ai/mcp-bundler";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
11
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
- import { resolveConfig } from "@alfe.ai/config";
8
- import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
- import { assertPatternA } from "@alfe.ai/mcp-bundler";
10
- //#region src/server.ts
11
- /**
12
- * Notion MCP Proxy Server (Pattern A multi-account)
13
- *
14
- * A thin proxy that fans out to one official `@notionhq/notion-mcp-server`
15
- * child process per connected Notion workspace. Every credential-touching
16
- * tool requires the LLM to pass a `workspaceId` selector arg; the proxy
17
- * strips that arg, dispatches to the right child, and returns the result.
18
- *
19
- * Pattern A locked in PR 7.2 of channels-and-credential-driven-
20
- * integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
21
- * contract; `services/connect/DEVELOPING.md` for the provider table.
22
- *
23
- * Architecture:
24
- * OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × @notionhq/notion-mcp-server
25
- *
26
- * Notion access tokens persist until the user revokes the integration,
27
- * so there's no token-refresh path.
28
- *
29
- * Uses the low-level Server class (not McpServer) because child tools
30
- * return JSON Schema objects — McpServer.registerTool requires Zod.
31
- */
32
- const workspaces = /* @__PURE__ */ new Map();
33
- /**
34
- * Full snapshot of every Notion connection returned by getNotionAccounts(),
35
- * including ones we couldn't spawn a child for (e.g. missing access token).
36
- * `notion_list_accounts` returns this so the LLM can surface partial-failure
37
- * connections instead of silently dropping them.
38
- */
39
- const allAccountsSnapshot = [];
40
- /** Cached, selector-injected tool list returned to OpenClaw. */
41
- let cachedTools = [];
42
- function log(msg) {
43
- process.stderr.write(`[notion-mcp-proxy] ${msg}\n`);
44
- }
45
- function resolveWorkspace(workspaceId) {
46
- if (!workspaceId) throw new Error("Missing required workspaceId argument. Call notion_list_accounts to see the connected Notion workspaces and pass the workspaceId you want to target.");
47
- const ws = workspaces.get(workspaceId);
48
- if (!ws) {
49
- const known = allAccountsSnapshot.find((s) => s.workspaceId === workspaceId);
50
- if (known && !known.connected) throw new Error(`workspaceId ${workspaceId} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Notion workspace from the dashboard.`);
51
- throw new Error(`Unknown workspaceId: ${workspaceId}. Call notion_list_accounts to see the connected Notion workspaces on this agent.`);
14
+ import { createHash } from "node:crypto";
15
+ const MAX_FORWARDED_ARGUMENT_BYTES = 2 * 1024 * 1024;
16
+ const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
17
+ const MAX_SCHEMA_BYTES = 768 * 1024;
18
+ const MAX_CHECK_RESPONSE_BYTES = 64 * 1024;
19
+ const MAX_JSON_DEPTH = 16;
20
+ const MAX_JSON_NODES = 1e5;
21
+ const MAX_JSON_ARRAY_ITEMS = 5e3;
22
+ const MAX_ARGUMENT_STRING_CHARS = 15e5;
23
+ const MAX_TOKEN_CHARS = 32768;
24
+ const MAX_ERROR_CHARS = 2048;
25
+ const NOTION_CHECK_TIMEOUT_MS = 15e3;
26
+ const WORKSPACE_ID_PATTERN = /^[A-Za-z0-9_-]{1,256}$/u;
27
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
28
+ const UNSAFE_KEYS = new Set([
29
+ "__proto__",
30
+ "constructor",
31
+ "prototype"
32
+ ]);
33
+ const RESERVED_TOOLS = new Set(["notion_list_accounts", "notion_check_connection"]);
34
+ const CHILD_ENV_ALLOWLIST = [
35
+ "HOME",
36
+ "TMPDIR",
37
+ "TMP",
38
+ "TEMP",
39
+ "LANG",
40
+ "LC_ALL",
41
+ "LC_CTYPE",
42
+ "TZ",
43
+ "SSL_CERT_FILE",
44
+ "SSL_CERT_DIR",
45
+ "NODE_EXTRA_CA_CERTS",
46
+ "HTTP_PROXY",
47
+ "HTTPS_PROXY",
48
+ "NO_PROXY",
49
+ "http_proxy",
50
+ "https_proxy",
51
+ "no_proxy"
52
+ ];
53
+ function buildChildEnvironment(accessToken, source = process.env) {
54
+ const token = validateAccessToken(accessToken, false);
55
+ const environment = Object.create(null);
56
+ for (const name of CHILD_ENV_ALLOWLIST) {
57
+ const value = source[name];
58
+ if (value !== void 0 && !containsControlCharacter(value)) environment[name] = value;
52
59
  }
53
- return ws;
60
+ return {
61
+ ...environment,
62
+ NOTION_TOKEN: token
63
+ };
54
64
  }
55
- async function spawnChild(accessToken) {
56
- const transport = new StdioClientTransport({
57
- command: "npx",
58
- args: ["-y", "@notionhq/notion-mcp-server"],
59
- env: {
60
- ...process.env,
61
- NOTION_TOKEN: accessToken
62
- }
63
- });
64
- const client = new Client({
65
- name: "notion-mcp-proxy",
66
- version: "2.0.0"
67
- });
68
- await client.connect(transport);
69
- return client;
65
+ function normalizeNotionAccount(value) {
66
+ if (!isRecord(value)) throw new Error("Notion account must be an object");
67
+ return {
68
+ connectionId: validateText("connectionId", value.connectionId, 128),
69
+ workspaceId: validateWorkspaceId(value.workspaceId),
70
+ workspaceName: validateDisplayText("workspace name", value.workspaceName, 512),
71
+ connectedAt: validateConnectedAt(value.connectedAt),
72
+ accessToken: validateAccessToken(value.accessToken, true)
73
+ };
70
74
  }
71
- async function killAllChildren() {
72
- for (const ws of workspaces.values()) try {
73
- await ws.client.close();
74
- } catch {}
75
- workspaces.clear();
75
+ function assertAccountCount(value) {
76
+ if (!Array.isArray(value)) throw new Error("Notion accounts response must be an array");
77
+ if (value.length > 50) throw new Error(`Notion accounts response exceeds ${String(50)} accounts`);
78
+ }
79
+ function readNotionAccountsResponse(value) {
80
+ if (!isRecord(value)) throw new Error("Notion accounts response must be an object");
81
+ assertAccountCount(value.accounts);
82
+ return value.accounts;
76
83
  }
77
- /**
78
- * Take a tool descriptor from the child Notion MCP server and inject a
79
- * required `workspaceId` string property into its inputSchema. The proxy
80
- * strips this arg before forwarding the call.
81
- */
82
84
  function injectWorkspaceSelector(tool) {
83
- const original = tool.inputSchema ?? {};
84
- const originalProperties = original.properties ?? {};
85
- const originalRequired = Array.isArray(original.required) ? original.required : [];
86
- const injectedProperties = {
85
+ if (!isRecord(tool)) throw new Error("Child tool descriptor must be an object");
86
+ const name = validateToolName(tool.name);
87
+ if (RESERVED_TOOLS.has(name)) throw new Error(`Child tool collides with reserved proxy tool: ${name}`);
88
+ const original = cloneJsonRecord("tool input schema", tool.inputSchema, MAX_SCHEMA_BYTES, 5e5);
89
+ const originalProperties = Object.hasOwn(original, "properties") ? cloneJsonRecord("tool schema properties", original.properties, MAX_SCHEMA_BYTES, 5e5) : Object.create(null);
90
+ const originalRequired = validateRequired(original.required);
91
+ const properties = {
87
92
  ...originalProperties,
88
93
  workspaceId: {
89
94
  type: "string",
90
- description: "Notion workspaceId — use the value from notion_list_accounts to pick which connected workspace this call should target."
95
+ pattern: WORKSPACE_ID_PATTERN.source,
96
+ maxLength: 256,
97
+ description: "Notion workspaceId from notion_list_accounts. This required selector chooses the exact connected OAuth workspace."
91
98
  }
92
99
  };
93
- const injectedRequired = originalRequired.includes("workspaceId") ? originalRequired : ["workspaceId", ...originalRequired];
94
- return {
95
- name: tool.name,
96
- description: tool.description,
100
+ const required = new Set(["workspaceId", ...originalRequired]);
101
+ if (name === "API-delete-a-block") {
102
+ properties.confirmBlockId = {
103
+ type: "string",
104
+ maxLength: 256,
105
+ description: "Repeat the exact block_id to confirm deletion of this Notion block."
106
+ };
107
+ required.add("confirmBlockId");
108
+ }
109
+ if (name === "API-update-a-block") {
110
+ const archived = properties.archived;
111
+ if (isRecord(archived)) properties.archived = {
112
+ ...archived,
113
+ default: false,
114
+ description: "Set true only to archive/delete this block; confirmBlockId must then exactly match block_id."
115
+ };
116
+ properties.confirmBlockId = {
117
+ type: "string",
118
+ maxLength: 256,
119
+ description: "Required only when archived=true; repeat the exact block_id to confirm deletion."
120
+ };
121
+ }
122
+ if (name === "API-patch-page") properties.confirmPageId = {
123
+ type: "string",
124
+ maxLength: 256,
125
+ description: "Required only when in_trash=true or archived=true; repeat the exact page_id to confirm deletion."
126
+ };
127
+ const result = {
128
+ name,
97
129
  inputSchema: {
98
130
  ...original,
99
- type: original.type ?? "object",
100
- properties: injectedProperties,
101
- required: injectedRequired
131
+ type: "object",
132
+ properties,
133
+ required: [...required]
102
134
  }
103
135
  };
136
+ if (tool.title !== void 0) result.title = validateText("tool title", tool.title, 256);
137
+ if (tool.description !== void 0) result.description = validateDocumentationText("tool description", tool.description, 2e4);
138
+ if (tool.outputSchema !== void 0) result.outputSchema = cloneJsonRecord("tool output schema", tool.outputSchema, MAX_SCHEMA_BYTES, 5e5);
139
+ if (tool.annotations !== void 0) result.annotations = validateAnnotations(tool.annotations);
140
+ if (tool.execution !== void 0) result.execution = validateExecution(tool.execution);
141
+ return result;
104
142
  }
105
- async function main() {
106
- const config = resolveConfig();
107
- const { accounts } = await new AgentApiClient({
108
- apiKey: config.apiKey,
109
- apiUrl: config.apiUrl
110
- }).getNotionAccounts();
111
- if (accounts.length === 0) log("No Notion workspaces connected — proxy will start with notion_list_accounts and notion_check_connection only");
112
- for (const acct of accounts) {
113
- if (!acct.accessToken) {
114
- log(`Skipping workspace ${acct.workspaceId} — no access token`);
115
- allAccountsSnapshot.push({
116
- workspaceId: acct.workspaceId,
117
- workspaceName: acct.workspaceName,
118
- connectedAt: acct.connectedAt,
119
- connected: false,
120
- reason: "missing_access_token"
121
- });
122
- continue;
123
- }
124
- if (workspaces.has(acct.workspaceId)) {
125
- log(`Duplicate workspaceId ${acct.workspaceId} returned by getNotionAccounts() — keeping the first cached child`);
126
- continue;
127
- }
143
+ function validateToolCatalog(tools) {
144
+ if (!Array.isArray(tools)) throw new Error("Child tool catalog must be an array");
145
+ if (tools.length > 100) throw new Error(`Child tool catalog exceeds ${String(100)} tools`);
146
+ const names = /* @__PURE__ */ new Set();
147
+ return tools.map((tool) => {
148
+ const proxied = injectWorkspaceSelector(tool);
149
+ if (names.has(proxied.name)) throw new Error(`Child tool catalog contains duplicate name: ${proxied.name}`);
150
+ names.add(proxied.name);
151
+ return proxied;
152
+ });
153
+ }
154
+ function prepareForwardedArguments(toolName, value) {
155
+ const cloned = cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
156
+ const workspaceId = validateWorkspaceId(cloned.workspaceId);
157
+ if (toolName === "API-delete-a-block") requireConfirmation(cloned, "block_id", "confirmBlockId");
158
+ if (toolName === "API-update-a-block" && cloned.archived === true) requireConfirmation(cloned, "block_id", "confirmBlockId");
159
+ if (toolName === "API-patch-page" && (cloned.in_trash === true || cloned.archived === true)) requireConfirmation(cloned, "page_id", "confirmPageId");
160
+ const proxyFields = new Set([
161
+ "workspaceId",
162
+ "confirmBlockId",
163
+ "confirmPageId"
164
+ ]);
165
+ return {
166
+ workspaceId,
167
+ forwarded: Object.fromEntries(Object.entries(cloned).filter(([key]) => !proxyFields.has(key)))
168
+ };
169
+ }
170
+ function assertBoundedToolArguments(value) {
171
+ return cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
172
+ }
173
+ function assertBoundedToolResult(value) {
174
+ return cloneJsonRecord("child tool result", value, MAX_TOOL_RESULT_BYTES, MAX_TOOL_RESULT_BYTES);
175
+ }
176
+ function childResultIsError(value) {
177
+ if (value.isError === true) return true;
178
+ if (isRecord(value.structuredContent) && value.structuredContent.status === "error") return true;
179
+ const content = value.content;
180
+ if (!Array.isArray(content)) return false;
181
+ return content.some((entry) => {
182
+ if (!isRecord(entry) || typeof entry.text !== "string") return false;
128
183
  try {
129
- const client = await spawnChild(acct.accessToken);
130
- workspaces.set(acct.workspaceId, {
131
- workspaceId: acct.workspaceId,
132
- workspaceName: acct.workspaceName,
133
- accessToken: acct.accessToken,
134
- client
135
- });
136
- allAccountsSnapshot.push({
137
- workspaceId: acct.workspaceId,
138
- workspaceName: acct.workspaceName,
139
- connectedAt: acct.connectedAt,
140
- connected: true
141
- });
142
- log(`Spawned child server for workspace ${acct.workspaceId} (${acct.workspaceName || "unnamed"})`);
143
- } catch (err) {
144
- const message = err instanceof Error ? err.message : String(err);
145
- log(`Failed to spawn child for workspace ${acct.workspaceId}: ${message}`);
146
- allAccountsSnapshot.push({
147
- workspaceId: acct.workspaceId,
148
- workspaceName: acct.workspaceName,
149
- connectedAt: acct.connectedAt,
150
- connected: false,
151
- reason: `spawn_failed: ${message}`
152
- });
184
+ const parsed = JSON.parse(entry.text);
185
+ return isRecord(parsed) && parsed.status === "error";
186
+ } catch {
187
+ return false;
153
188
  }
189
+ });
190
+ }
191
+ function extractToolErrorText(value) {
192
+ if (!Array.isArray(value.content)) return "";
193
+ return value.content.slice(0, 100).map((entry) => isRecord(entry) && typeof entry.text === "string" ? entry.text.slice(0, MAX_ERROR_CHARS) : "").join("\n").slice(0, MAX_ERROR_CHARS);
194
+ }
195
+ async function checkNotionConnection(accessToken, fetchFn = fetch) {
196
+ const response = await fetchFn("https://api.notion.com/v1/users/me", {
197
+ headers: {
198
+ Authorization: `Bearer ${validateAccessToken(accessToken, false)}`,
199
+ "Notion-Version": "2025-09-03"
200
+ },
201
+ redirect: "error",
202
+ signal: AbortSignal.timeout(NOTION_CHECK_TIMEOUT_MS)
203
+ });
204
+ if (!response.ok) {
205
+ await response.body?.cancel().catch(() => void 0);
206
+ return {
207
+ ok: false,
208
+ status: response.status
209
+ };
154
210
  }
155
- const firstWorkspace = workspaces.values().next().value;
156
- if (firstWorkspace) {
157
- const { tools } = await firstWorkspace.client.listTools();
158
- cachedTools = tools.map(injectWorkspaceSelector);
159
- log(`Child MCP server provides ${String(cachedTools.length)} tools (selector injected)`);
160
- } else {
161
- cachedTools = [];
162
- log("No child server available — only notion_list_accounts and notion_check_connection will be exposed");
211
+ const text = await readResponseText(response, MAX_CHECK_RESPONSE_BYTES);
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(text);
215
+ } catch {
216
+ throw new Error("Notion /users/me returned invalid JSON");
163
217
  }
164
- cachedTools.push({
165
- name: "notion_list_accounts",
166
- description: "List the Notion workspaces the agent has connected. Returns one entry per OAuth connection — use the returned workspaceId values as the `workspaceId` selector arg on every other Notion tool.",
167
- inputSchema: {
168
- type: "object",
169
- properties: {}
218
+ if (!isRecord(parsed) || parsed.object !== "user") throw new Error("Notion /users/me returned an invalid user object");
219
+ validateText("Notion user id", parsed.id, 128);
220
+ if (parsed.type !== "bot" && parsed.type !== "person") throw new Error("Notion /users/me returned an invalid user type");
221
+ return {
222
+ ok: true,
223
+ status: response.status
224
+ };
225
+ }
226
+ function snapshotToolCatalog(tools) {
227
+ return tools.map((tool) => structuredClone(tool));
228
+ }
229
+ function safeErrorMessage(error, secrets = []) {
230
+ let message = (error instanceof Error ? error.message : String(error)).slice(0, MAX_ERROR_CHARS);
231
+ for (const secret of secrets) if (secret.length >= 4) message = message.split(secret).join("[REDACTED]");
232
+ return flattenControls(message).replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/giu, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/giu, "https://[REDACTED]@").replace(/\balfe_[A-Za-z0-9_-]{8,}/gu, "[REDACTED]").slice(0, MAX_ERROR_CHARS);
233
+ }
234
+ function connectionLogId(value) {
235
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
236
+ }
237
+ async function withDeadline(promise, timeoutMs, label) {
238
+ let timer;
239
+ try {
240
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
241
+ timer = setTimeout(() => {
242
+ reject(/* @__PURE__ */ new Error(`${label} timed out`));
243
+ }, timeoutMs);
244
+ timer.unref();
245
+ })]);
246
+ } finally {
247
+ if (timer !== void 0) clearTimeout(timer);
248
+ }
249
+ }
250
+ function requireConfirmation(value, targetField, confirmationField) {
251
+ const target = value[targetField];
252
+ const confirmation = value[confirmationField];
253
+ if (typeof target !== "string" || target.length < 1) throw new Error(`${targetField} must be a non-empty string`);
254
+ if (confirmation !== target) throw new Error(`${confirmationField} must exactly match ${targetField}`);
255
+ }
256
+ function validateWorkspaceId(value) {
257
+ if (typeof value !== "string" || !WORKSPACE_ID_PATTERN.test(value)) throw new Error("workspaceId must contain 1 to 256 letters, digits, underscores, or hyphens");
258
+ return value;
259
+ }
260
+ function validateAccessToken(value, allowEmpty) {
261
+ if (allowEmpty && value === "") return "";
262
+ if (typeof value !== "string" || value.length < 8 || value.length > MAX_TOKEN_CHARS || containsControlCharacter(value) || value !== value.trim()) throw new Error("Notion access token is invalid");
263
+ return value;
264
+ }
265
+ function validateConnectedAt(value) {
266
+ const checked = validateText("connectedAt", value, 64);
267
+ const parsed = new Date(checked);
268
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== checked) throw new Error("connectedAt must be a canonical ISO date-time");
269
+ return checked;
270
+ }
271
+ function validateToolName(value) {
272
+ if (typeof value !== "string" || !TOOL_NAME_PATTERN.test(value)) throw new Error("Child tool name is invalid");
273
+ return value;
274
+ }
275
+ function validateText(label, value, maxChars) {
276
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
277
+ return value;
278
+ }
279
+ function validateDisplayText(label, value, maxChars) {
280
+ if (typeof value !== "string" || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain at most ${String(maxChars)} non-control characters`);
281
+ return value;
282
+ }
283
+ function validateDocumentationText(label, value, maxChars) {
284
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || Array.from(value).some((character) => {
285
+ const codePoint = character.codePointAt(0) ?? 0;
286
+ return codePoint < 32 && character !== "\n" && character !== "\r" && character !== " " || codePoint === 127;
287
+ })) throw new Error(`${label} must contain 1 to ${String(maxChars)} safe characters`);
288
+ return value;
289
+ }
290
+ function validateRequired(value) {
291
+ if (value === void 0) return [];
292
+ if (!Array.isArray(value) || value.length > 100) throw new Error("tool schema required must be an array");
293
+ const result = [];
294
+ for (const entry of value) {
295
+ if (typeof entry !== "string" || entry.length < 1 || entry.length > 128) throw new Error("tool schema required contains an invalid property name");
296
+ if (!result.includes(entry)) result.push(entry);
297
+ }
298
+ return result;
299
+ }
300
+ function validateAnnotations(value) {
301
+ if (!isRecord(value)) throw new Error("tool annotations must be an object");
302
+ const result = {};
303
+ if (value.title !== void 0) result.title = validateText("annotation title", value.title, 256);
304
+ for (const key of [
305
+ "readOnlyHint",
306
+ "destructiveHint",
307
+ "idempotentHint",
308
+ "openWorldHint"
309
+ ]) {
310
+ const hint = value[key];
311
+ if (hint !== void 0) {
312
+ if (typeof hint !== "boolean") throw new Error(`tool annotation ${key} must be boolean`);
313
+ result[key] = hint;
170
314
  }
171
- });
172
- cachedTools.push({
173
- name: "notion_check_connection",
174
- description: "Verify the Notion OAuth2 connection for a specific connected workspace is still valid. Use this if Notion API calls are failing with authentication errors.",
175
- inputSchema: {
176
- type: "object",
177
- properties: { workspaceId: {
178
- type: "string",
179
- description: "Notion workspaceId — use the value from notion_list_accounts."
180
- } },
181
- required: ["workspaceId"]
315
+ }
316
+ return result;
317
+ }
318
+ function validateExecution(value) {
319
+ if (!isRecord(value)) throw new Error("tool execution metadata must be an object");
320
+ const taskSupport = value.taskSupport;
321
+ if (taskSupport !== void 0 && taskSupport !== "optional" && taskSupport !== "required" && taskSupport !== "forbidden") throw new Error("tool execution taskSupport is invalid");
322
+ return taskSupport === void 0 ? {} : { taskSupport };
323
+ }
324
+ async function readResponseText(response, maxBytes) {
325
+ const declared = response.headers.get("content-length");
326
+ if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > maxBytes)) {
327
+ await response.body?.cancel().catch(() => void 0);
328
+ throw new Error("Notion /users/me response exceeds the byte limit");
329
+ }
330
+ if (response.body === null) return "";
331
+ const reader = response.body.getReader();
332
+ const decoder = new TextDecoder();
333
+ let bytes = 0;
334
+ let text = "";
335
+ try {
336
+ for (;;) {
337
+ const result = await reader.read();
338
+ if (result.done) break;
339
+ if (result.value === void 0) throw new Error("Notion response stream returned no bytes");
340
+ bytes += result.value.byteLength;
341
+ if (bytes > maxBytes) {
342
+ await reader.cancel("response too large").catch(() => void 0);
343
+ throw new Error("Notion /users/me response exceeds the byte limit");
344
+ }
345
+ text += decoder.decode(result.value, { stream: true });
182
346
  }
347
+ text += decoder.decode();
348
+ return text;
349
+ } finally {
350
+ reader.releaseLock();
351
+ }
352
+ }
353
+ function cloneJsonRecord(label, value, maxBytes, maxStringChars) {
354
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
355
+ const cloned = cloneRecord(label, value, 0, { nodes: 0 }, maxStringChars);
356
+ if (Buffer.byteLength(JSON.stringify(cloned), "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
357
+ return cloned;
358
+ }
359
+ function cloneJson(label, value, depth, budget, maxStringChars) {
360
+ budget.nodes += 1;
361
+ if (budget.nodes > MAX_JSON_NODES) throw new Error(`${label} contains too many values`);
362
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
363
+ if (value === null || typeof value === "boolean") return value;
364
+ if (typeof value === "number") {
365
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
366
+ return value;
367
+ }
368
+ if (typeof value === "string") {
369
+ if (value.length > maxStringChars) throw new Error(`${label} contains an oversized string`);
370
+ return value;
371
+ }
372
+ if (Array.isArray(value)) {
373
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error(`${label} contains an oversized array`);
374
+ return value.map((entry) => cloneJson(label, entry, depth + 1, budget, maxStringChars));
375
+ }
376
+ if (!isRecord(value)) throw new Error(`${label} contains a non-JSON value`);
377
+ return cloneRecord(label, value, depth, budget, maxStringChars);
378
+ }
379
+ function cloneRecord(label, value, depth, budget, maxStringChars) {
380
+ const result = Object.create(null);
381
+ for (const [key, nested] of Object.entries(value)) {
382
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key)) throw new Error(`${label} contains an unsafe property name`);
383
+ result[key] = cloneJson(label, nested, depth + 1, budget, maxStringChars);
384
+ }
385
+ return result;
386
+ }
387
+ function containsControlCharacter(value) {
388
+ return Array.from(value).some((character) => {
389
+ const codePoint = character.codePointAt(0) ?? 0;
390
+ return codePoint < 32 || codePoint === 127;
183
391
  });
184
- assertPatternA(cachedTools.map((t) => ({
185
- name: t.name,
186
- parameters: t.inputSchema
187
- })), {
188
- selector: "workspaceId",
189
- exempt: ["notion_list_accounts"]
190
- });
191
- const proxy = new Server({
192
- name: "notion-mcp-proxy",
193
- version: "2.0.0"
194
- }, { capabilities: { tools: {} } });
195
- proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
196
- proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
197
- const { name, arguments: args } = request.params;
198
- const argMap = args ?? {};
199
- if (name === "notion_list_accounts") return { content: [{
200
- type: "text",
201
- text: JSON.stringify({ workspaces: allAccountsSnapshot }, null, 2)
202
- }] };
203
- if (name === "notion_check_connection") try {
204
- const ws = resolveWorkspace(typeof argMap.workspaceId === "string" ? argMap.workspaceId : void 0);
205
- const response = await fetch("https://api.notion.com/v1/users/me", { headers: {
206
- Authorization: `Bearer ${ws.accessToken}`,
207
- "Notion-Version": "2022-06-28"
208
- } });
209
- if (!response.ok) return {
210
- content: [{
211
- type: "text",
212
- text: JSON.stringify({
213
- workspaceId: ws.workspaceId,
214
- connected: false,
215
- status: response.status,
216
- error: "Token may have been revoked. Ask the user to reconnect this Notion workspace from the dashboard."
217
- })
218
- }],
219
- isError: true
220
- };
221
- const user = await response.json();
222
- return { content: [{
223
- type: "text",
224
- text: JSON.stringify({
225
- workspaceId: ws.workspaceId,
392
+ }
393
+ function flattenControls(value) {
394
+ let output = "";
395
+ let previousWasControl = false;
396
+ for (const character of value) {
397
+ const codePoint = character.codePointAt(0) ?? 0;
398
+ const isControl = codePoint < 32 || codePoint === 127;
399
+ if (isControl) {
400
+ if (!previousWasControl) output += " ";
401
+ } else output += character;
402
+ previousWasControl = isControl;
403
+ }
404
+ return output;
405
+ }
406
+ function isRecord(value) {
407
+ return value !== null && typeof value === "object" && !Array.isArray(value);
408
+ }
409
+ //#endregion
410
+ //#region src/server.ts
411
+ /**
412
+ * Notion MCP proxy (Pattern A multi-account).
413
+ *
414
+ * OpenClaw communicates with this process over stdio. The proxy owns one
415
+ * exact-version official Notion child per connected workspace and injects a
416
+ * required workspaceId selector into every credential-touching child tool.
417
+ */
418
+ const require = createRequire(import.meta.url);
419
+ const CHILD_PACKAGE = "@notionhq/notion-mcp-server";
420
+ const packageMetadata = require("../package.json");
421
+ const childPackagePath = require.resolve(`${CHILD_PACKAGE}/package.json`);
422
+ const childMetadata = require(childPackagePath);
423
+ const SERVER_VERSION = validateSemver("notion-mcp package version", packageMetadata.version);
424
+ const CHILD_VERSION = validateExactChildVersion(packageMetadata.dependencies?.[CHILD_PACKAGE], childMetadata.version);
425
+ const CHILD_BIN_PATH = resolveChildBin(childPackagePath, childMetadata.bin);
426
+ const CHILD_START_TIMEOUT_MS = 3e4;
427
+ const CHILD_CATALOG_TIMEOUT_MS = 15e3;
428
+ const CHILD_CALL_TIMEOUT_MS = 6e4;
429
+ const CHILD_CLOSE_TIMEOUT_MS = 5e3;
430
+ var NotionRuntime = class {
431
+ workspaces = /* @__PURE__ */ new Map();
432
+ claimedWorkspaceIds = /* @__PURE__ */ new Map();
433
+ allAccountsSnapshot = [];
434
+ catalogToolNames = /* @__PURE__ */ new Set();
435
+ cachedTools = [];
436
+ closePromise;
437
+ spawnChild;
438
+ fetchImpl;
439
+ constructor(apiClient, options = {}) {
440
+ this.apiClient = apiClient;
441
+ this.spawnChild = options.spawnChild ?? spawnOfficialChild;
442
+ this.fetchImpl = options.fetchImpl ?? fetch;
443
+ }
444
+ async initialize() {
445
+ await this.loadAccounts();
446
+ this.cachedTools = appendLocalTools(await this.discoverChildCatalog());
447
+ assertPatternA(this.cachedTools.map((tool) => ({
448
+ name: tool.name,
449
+ parameters: tool.inputSchema
450
+ })), {
451
+ selector: "workspaceId",
452
+ exempt: ["notion_list_accounts"]
453
+ });
454
+ }
455
+ listTools() {
456
+ return snapshotToolCatalog(this.cachedTools);
457
+ }
458
+ async handleToolCall(request) {
459
+ const { name } = request.params;
460
+ const args = request.params.arguments ?? {};
461
+ if (name === "notion_list_accounts") try {
462
+ const bounded = assertBoundedToolArguments(args);
463
+ if (Object.keys(bounded).length !== 0) throw new Error("notion_list_accounts takes no arguments");
464
+ return jsonResult({ workspaces: this.allAccountsSnapshot });
465
+ } catch (error) {
466
+ return errorResult(error);
467
+ }
468
+ if (name === "notion_check_connection") {
469
+ let workspace;
470
+ try {
471
+ const prepared = prepareForwardedArguments(name, args);
472
+ if (Object.keys(prepared.forwarded).length !== 0) throw new Error("notion_check_connection accepts only workspaceId");
473
+ workspace = this.resolveWorkspace(prepared.workspaceId);
474
+ const result = await checkNotionConnection(workspace.accessToken, this.fetchImpl);
475
+ if (!result.ok) return errorResult("Token may have been revoked. Ask the user to reconnect this Notion workspace from the dashboard.", {
476
+ workspaceId: workspace.workspaceId,
477
+ connected: false,
478
+ status: result.status
479
+ });
480
+ return jsonResult({
481
+ workspaceId: workspace.workspaceId,
226
482
  connected: true,
227
- user
228
- })
229
- }] };
230
- } catch (err) {
231
- return {
232
- content: [{
233
- type: "text",
234
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
235
- }],
236
- isError: true
237
- };
483
+ status: result.status
484
+ });
485
+ } catch (error) {
486
+ return errorResult(error, {}, workspace === void 0 ? [] : [workspace.accessToken]);
487
+ }
238
488
  }
239
- let ws;
489
+ if (!this.catalogToolNames.has(name)) return errorResult(`Unknown Notion tool: ${name}`);
490
+ let workspace;
491
+ let forwarded;
240
492
  try {
241
- ws = resolveWorkspace(typeof argMap.workspaceId === "string" ? argMap.workspaceId : void 0);
242
- } catch (err) {
243
- return {
244
- content: [{
245
- type: "text",
246
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
247
- }],
248
- isError: true
249
- };
493
+ const prepared = prepareForwardedArguments(name, args);
494
+ workspace = this.resolveWorkspace(prepared.workspaceId);
495
+ forwarded = prepared.forwarded;
496
+ } catch (error) {
497
+ return errorResult(error);
250
498
  }
251
- const { workspaceId: _ignored, ...forwarded } = argMap;
252
499
  try {
253
- return await ws.client.callTool({
500
+ const result = assertBoundedToolResult(await withDeadline(workspace.client.callTool({
254
501
  name,
255
502
  arguments: forwarded
256
- });
257
- } catch (err) {
258
- return {
259
- content: [{
260
- type: "text",
261
- text: JSON.stringify({
262
- workspaceId: ws.workspaceId,
263
- tool: name,
264
- error: err instanceof Error ? err.message : String(err)
265
- })
266
- }],
267
- isError: true
268
- };
503
+ }), CHILD_CALL_TIMEOUT_MS, `Notion ${name}`));
504
+ if (childResultIsError(result)) return errorResult(extractToolErrorText(result) || "Notion child tool returned an error", {
505
+ workspaceId: workspace.workspaceId,
506
+ tool: name
507
+ }, [workspace.accessToken]);
508
+ return result;
509
+ } catch (error) {
510
+ return errorResult(error, {
511
+ workspaceId: workspace.workspaceId,
512
+ tool: name
513
+ }, [workspace.accessToken]);
269
514
  }
270
- });
271
- const transport = new StdioServerTransport();
272
- await proxy.connect(transport);
273
- log(`Proxy running with ${String(workspaces.size)} connected workspace(s) and Pattern A selector enforcement`);
515
+ }
516
+ async close() {
517
+ if (this.closePromise !== void 0) return this.closePromise;
518
+ this.closePromise = (async () => {
519
+ const current = [...this.workspaces.values()];
520
+ this.workspaces.clear();
521
+ await Promise.all(current.map(async ({ client }) => closeChild(client)));
522
+ })();
523
+ return this.closePromise;
524
+ }
525
+ async loadAccounts() {
526
+ const rawAccounts = readNotionAccountsResponse(await this.apiClient.getNotionAccounts());
527
+ if (rawAccounts.length === 0) log("No Notion workspaces connected; starting in discovery-only mode");
528
+ for (const rawAccount of rawAccounts) {
529
+ let account;
530
+ try {
531
+ account = normalizeNotionAccount(rawAccount);
532
+ } catch (error) {
533
+ log(`Rejected invalid Notion connection ${typeof rawAccount.connectionId === "string" ? connectionLogId(rawAccount.connectionId) : "invalid"}: ${safeErrorMessage(error)}`);
534
+ this.allAccountsSnapshot.push({
535
+ workspaceId: null,
536
+ workspaceName: null,
537
+ connectedAt: null,
538
+ connected: false,
539
+ reason: "invalid_account"
540
+ });
541
+ continue;
542
+ }
543
+ if (this.claimedWorkspaceIds.has(account.workspaceId)) {
544
+ log(`Rejected duplicate Notion workspace from connection ${connectionLogId(account.connectionId)}`);
545
+ this.allAccountsSnapshot.push({
546
+ workspaceId: account.workspaceId,
547
+ workspaceName: account.workspaceName,
548
+ connectedAt: account.connectedAt,
549
+ connected: false,
550
+ reason: "duplicate_workspace"
551
+ });
552
+ continue;
553
+ }
554
+ this.claimedWorkspaceIds.set(account.workspaceId, account.connectionId);
555
+ if (account.accessToken.length === 0) {
556
+ log(`Missing token for Notion connection ${connectionLogId(account.connectionId)}`);
557
+ this.allAccountsSnapshot.push({
558
+ workspaceId: account.workspaceId,
559
+ workspaceName: account.workspaceName,
560
+ connectedAt: account.connectedAt,
561
+ connected: false,
562
+ reason: "missing_access_token"
563
+ });
564
+ continue;
565
+ }
566
+ try {
567
+ const controller = new AbortController();
568
+ let client;
569
+ try {
570
+ client = await withDeadline(this.spawnChild(account.accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Notion child startup");
571
+ } catch (error) {
572
+ controller.abort(error);
573
+ throw error;
574
+ }
575
+ this.workspaces.set(account.workspaceId, {
576
+ ...account,
577
+ client
578
+ });
579
+ this.allAccountsSnapshot.push({
580
+ workspaceId: account.workspaceId,
581
+ workspaceName: account.workspaceName,
582
+ connectedAt: account.connectedAt,
583
+ connected: true
584
+ });
585
+ log(`Spawned child for Notion connection ${connectionLogId(account.connectionId)}`);
586
+ } catch (error) {
587
+ log(`Child startup failed for Notion connection ${connectionLogId(account.connectionId)}: ${safeErrorMessage(error, [account.accessToken])}`);
588
+ this.allAccountsSnapshot.push({
589
+ workspaceId: account.workspaceId,
590
+ workspaceName: account.workspaceName,
591
+ connectedAt: account.connectedAt,
592
+ connected: false,
593
+ reason: "spawn_failed"
594
+ });
595
+ }
596
+ }
597
+ }
598
+ async discoverChildCatalog() {
599
+ for (const workspace of this.workspaces.values()) try {
600
+ const tools = validateToolCatalog((await withDeadline(workspace.client.listTools(), CHILD_CATALOG_TIMEOUT_MS, "Notion child tool discovery")).tools);
601
+ for (const tool of tools) this.catalogToolNames.add(tool.name);
602
+ return tools;
603
+ } catch (error) {
604
+ log(`Tool discovery failed for Notion connection ${connectionLogId(workspace.connectionId)}: ${safeErrorMessage(error, [workspace.accessToken])}`);
605
+ }
606
+ return [];
607
+ }
608
+ resolveWorkspace(workspaceId) {
609
+ const workspace = this.workspaces.get(workspaceId);
610
+ if (workspace !== void 0) return workspace;
611
+ const known = this.allAccountsSnapshot.find((summary) => summary.workspaceId === workspaceId);
612
+ if (known !== void 0) throw new Error(`workspaceId ${workspaceId} is connected on this agent but its Notion credential process is unavailable (${known.reason ?? "unknown"}). Ask the user to reconnect this workspace from the dashboard.`);
613
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call notion_list_accounts to see the connected Notion workspaces on this agent.`);
614
+ }
615
+ };
616
+ function appendLocalTools(tools) {
617
+ return [
618
+ ...tools,
619
+ {
620
+ name: "notion_list_accounts",
621
+ description: "List every connected Notion workspace and its local proxy readiness. Use workspaceId as the explicit selector on every other Notion tool.",
622
+ inputSchema: {
623
+ type: "object",
624
+ properties: {},
625
+ additionalProperties: false
626
+ },
627
+ annotations: {
628
+ readOnlyHint: true,
629
+ destructiveHint: false,
630
+ idempotentHint: true,
631
+ openWorldHint: false
632
+ }
633
+ },
634
+ {
635
+ name: "notion_check_connection",
636
+ description: "Verify that one selected Notion OAuth token is still valid. The result exposes only status, never the credential or Notion user profile.",
637
+ inputSchema: {
638
+ type: "object",
639
+ properties: { workspaceId: {
640
+ type: "string",
641
+ pattern: "^[A-Za-z0-9_-]{1,256}$",
642
+ maxLength: 256,
643
+ description: "Notion workspaceId from notion_list_accounts."
644
+ } },
645
+ required: ["workspaceId"],
646
+ additionalProperties: false
647
+ },
648
+ annotations: {
649
+ readOnlyHint: true,
650
+ destructiveHint: false,
651
+ idempotentHint: true,
652
+ openWorldHint: true
653
+ }
654
+ }
655
+ ];
274
656
  }
275
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
276
- killAllChildren().then(() => {
277
- process.exit(0);
657
+ async function spawnOfficialChild(accessToken, signal) {
658
+ const transport = new StdioClientTransport({
659
+ command: process.execPath,
660
+ args: [CHILD_BIN_PATH],
661
+ env: buildChildEnvironment(accessToken),
662
+ stderr: "ignore"
278
663
  });
279
- });
280
- main().catch((err) => {
281
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
282
- killAllChildren().finally(() => {
283
- process.exit(1);
664
+ const client = new Client({
665
+ name: "notion-mcp-proxy",
666
+ version: SERVER_VERSION
284
667
  });
668
+ const abort = () => {
669
+ closeChild(client);
670
+ };
671
+ signal.addEventListener("abort", abort, { once: true });
672
+ try {
673
+ await client.connect(transport);
674
+ if (signal.aborted) throw new Error("Notion child startup was aborted");
675
+ return client;
676
+ } catch (error) {
677
+ await closeChild(client);
678
+ throw error;
679
+ } finally {
680
+ signal.removeEventListener("abort", abort);
681
+ }
682
+ }
683
+ async function closeChild(client) {
684
+ await withDeadline(client.close(), CHILD_CLOSE_TIMEOUT_MS, "Notion child close").catch(() => void 0);
685
+ }
686
+ async function createProxyServer(apiClient, options = {}) {
687
+ const runtime = new NotionRuntime(apiClient, options);
688
+ try {
689
+ await runtime.initialize();
690
+ } catch (error) {
691
+ await runtime.close();
692
+ throw error;
693
+ }
694
+ const server = new Server({
695
+ name: "notion-mcp-proxy",
696
+ version: SERVER_VERSION
697
+ }, { capabilities: { tools: {} } });
698
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: runtime.listTools() }));
699
+ server.setRequestHandler(CallToolRequestSchema, (request) => runtime.handleToolCall(request));
700
+ return {
701
+ server,
702
+ runtime
703
+ };
704
+ }
705
+ async function startServer(apiClient, options = {}) {
706
+ let client = apiClient;
707
+ if (client === void 0) {
708
+ const config = resolveConfig();
709
+ client = new AgentApiClient({
710
+ apiKey: config.apiKey,
711
+ apiUrl: config.apiUrl
712
+ });
713
+ }
714
+ const { server, runtime } = await createProxyServer(client, options);
715
+ let closePromise;
716
+ const close = () => {
717
+ closePromise ??= Promise.all([runtime.close(), withDeadline(server.close(), CHILD_CLOSE_TIMEOUT_MS, "Notion proxy close").catch(() => void 0)]).then(() => void 0);
718
+ return closePromise;
719
+ };
720
+ try {
721
+ await server.connect(new StdioServerTransport());
722
+ log(`Proxy running with ${String(runtime.listTools().length - 2)} child tool(s)`);
723
+ return {
724
+ server,
725
+ runtime,
726
+ close
727
+ };
728
+ } catch (error) {
729
+ await close();
730
+ throw error;
731
+ }
732
+ }
733
+ function isProcessEntrypoint(argvPath, metaUrl) {
734
+ try {
735
+ if (!argvPath) return false;
736
+ return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(fileURLToPath(metaUrl))).href;
737
+ } catch {
738
+ return false;
739
+ }
740
+ }
741
+ function resolveChildBin(packagePath, bin) {
742
+ if (bin === null || typeof bin !== "object" || Array.isArray(bin)) throw new Error(`${CHILD_PACKAGE} package bin metadata is invalid`);
743
+ const relativeBin = bin["notion-mcp-server"];
744
+ if (typeof relativeBin !== "string" || relativeBin.length === 0 || isAbsolute(relativeBin)) throw new Error(`${CHILD_PACKAGE} package bin path is invalid`);
745
+ const packageRoot = realpathSync(dirname(packagePath));
746
+ const binPath = realpathSync(resolve(packageRoot, relativeBin));
747
+ const childRelative = relative(packageRoot, binPath);
748
+ if (childRelative === "" || childRelative.startsWith("..") || isAbsolute(childRelative)) throw new Error(`${CHILD_PACKAGE} package bin resolves outside its package`);
749
+ return binPath;
750
+ }
751
+ function validateExactChildVersion(declared, installed) {
752
+ const declaredVersion = validateSemver(`${CHILD_PACKAGE} dependency`, declared);
753
+ const installedVersion = validateSemver(`${CHILD_PACKAGE} installed version`, installed);
754
+ if (declaredVersion !== installedVersion) throw new Error(`${CHILD_PACKAGE} installed version ${installedVersion} does not match exact dependency ${declaredVersion}`);
755
+ return declaredVersion;
756
+ }
757
+ function validateSemver(label, value) {
758
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value)) throw new Error(`${label} is invalid`);
759
+ return value;
760
+ }
761
+ function jsonResult(data) {
762
+ return { content: [{
763
+ type: "text",
764
+ text: JSON.stringify(data)
765
+ }] };
766
+ }
767
+ function errorResult(error, context = {}, secrets = []) {
768
+ return {
769
+ content: [{
770
+ type: "text",
771
+ text: JSON.stringify({
772
+ ...context,
773
+ error: safeErrorMessage(error, secrets)
774
+ })
775
+ }],
776
+ isError: true
777
+ };
778
+ }
779
+ function log(message) {
780
+ process.stderr.write(`[notion-mcp-proxy] ${message}\n`);
781
+ }
782
+ if (isProcessEntrypoint(process.argv[1], import.meta.url)) startServer().then((running) => {
783
+ let shutdownPromise;
784
+ const shutdown = () => {
785
+ if (shutdownPromise !== void 0) return;
786
+ shutdownPromise = running.close().catch((error) => {
787
+ log(`Failed to close cleanly: ${safeErrorMessage(error)}`);
788
+ }).then(() => process.exit(0));
789
+ };
790
+ process.once("SIGTERM", shutdown);
791
+ process.once("SIGINT", shutdown);
792
+ }).catch((error) => {
793
+ log(`Fatal: ${safeErrorMessage(error)}`);
794
+ process.exitCode = 1;
285
795
  });
286
796
  //#endregion
287
- export {};
797
+ export { CHILD_BIN_PATH, CHILD_VERSION, NotionRuntime, SERVER_VERSION, createProxyServer, isProcessEntrypoint, startServer };