notion-mcp-server 1.0.1 → 2.4.2

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.
Files changed (99) hide show
  1. package/README.md +383 -192
  2. package/build/config/index.js +3 -1
  3. package/build/dispatch/concurrency.js +15 -0
  4. package/build/dispatch/idempotency.js +38 -0
  5. package/build/dispatch/index.js +175 -0
  6. package/build/dispatch/rate-limit.js +56 -0
  7. package/build/dispatch/retry.js +97 -0
  8. package/build/index.js +1 -1
  9. package/build/markdown/parse.js +265 -0
  10. package/build/operations/blocks.js +331 -0
  11. package/build/operations/comments.js +191 -0
  12. package/build/operations/data-sources.js +85 -0
  13. package/build/operations/databases.js +345 -0
  14. package/build/operations/files.js +239 -0
  15. package/build/operations/index.js +19 -0
  16. package/build/operations/pages.js +486 -0
  17. package/build/operations/registry.js +16 -0
  18. package/build/operations/users.js +101 -0
  19. package/build/prompts/index.js +105 -0
  20. package/build/schema/blocks.js +19 -138
  21. package/build/schema/database.js +27 -111
  22. package/build/schema/emit.js +68 -0
  23. package/build/schema/file.js +1 -1
  24. package/build/schema/filter-dsl.js +333 -0
  25. package/build/schema/icon.js +1 -1
  26. package/build/schema/page-properties.js +17 -3
  27. package/build/schema/page.js +12 -125
  28. package/build/schema/refs.js +16 -0
  29. package/build/schema/rich-text.js +1 -1
  30. package/build/server/index.js +16 -3
  31. package/build/services/auth.js +19 -0
  32. package/build/services/notion.js +14 -17
  33. package/build/tools/index.js +119 -21
  34. package/build/utils/error.js +125 -86
  35. package/build/utils/handler.js +11 -0
  36. package/build/utils/learning-error.js +40 -0
  37. package/build/utils/notion-types.js +16 -0
  38. package/build/utils/paginate.js +35 -0
  39. package/build/utils/schema-slice.js +156 -0
  40. package/build/utils/slim.js +269 -0
  41. package/package.json +13 -7
  42. package/build/resources/imageList.js +0 -62
  43. package/build/resources/index.js +0 -1
  44. package/build/resources/predictionList.js +0 -43
  45. package/build/resources/svgList.js +0 -69
  46. package/build/schema/comments.js +0 -60
  47. package/build/schema/notion.js +0 -57
  48. package/build/schema/richText.js +0 -757
  49. package/build/schema/tools.js +0 -17
  50. package/build/schema/users.js +0 -39
  51. package/build/services/loggs.js +0 -13
  52. package/build/services/replicate.js +0 -23
  53. package/build/tools/appendBlockChildren.js +0 -25
  54. package/build/tools/batchAppendBlockChildren.js +0 -33
  55. package/build/tools/batchDeleteBlocks.js +0 -32
  56. package/build/tools/batchMixedOperations.js +0 -58
  57. package/build/tools/batchUpdateBlocks.js +0 -33
  58. package/build/tools/blocks.js +0 -34
  59. package/build/tools/comments.js +0 -81
  60. package/build/tools/createDatabase.js +0 -18
  61. package/build/tools/createPage.js +0 -18
  62. package/build/tools/createPrediction.js +0 -28
  63. package/build/tools/database.js +0 -16
  64. package/build/tools/deleteBlock.js +0 -24
  65. package/build/tools/formatRichText.js +0 -83
  66. package/build/tools/generateImage.js +0 -48
  67. package/build/tools/generateImageVariants.js +0 -105
  68. package/build/tools/generateMultipleImages.js +0 -60
  69. package/build/tools/generateSVG.js +0 -43
  70. package/build/tools/getPrediction.js +0 -22
  71. package/build/tools/pages.js +0 -22
  72. package/build/tools/predictionList.js +0 -30
  73. package/build/tools/queryDatabase.js +0 -22
  74. package/build/tools/retrieveBlock.js +0 -24
  75. package/build/tools/retrieveBlockChildren.js +0 -32
  76. package/build/tools/searchPage.js +0 -24
  77. package/build/tools/updateBlock.js +0 -25
  78. package/build/tools/updateDatabase.js +0 -18
  79. package/build/tools/updatePage.js +0 -40
  80. package/build/tools/updatePageProperties.js +0 -21
  81. package/build/tools/users.js +0 -75
  82. package/build/types/blocks.js +0 -12
  83. package/build/types/comments.js +0 -7
  84. package/build/types/database.js +0 -6
  85. package/build/types/notion.js +0 -1
  86. package/build/types/page.js +0 -8
  87. package/build/types/richText.js +0 -1
  88. package/build/types/tools.js +0 -1
  89. package/build/types/users.js +0 -6
  90. package/build/utils/blob.js +0 -5
  91. package/build/utils/image.js +0 -34
  92. package/build/utils/index.js +0 -1
  93. package/build/utils/richText.js +0 -174
  94. package/build/validation/blocks.js +0 -568
  95. package/build/validation/notion.js +0 -51
  96. package/build/validation/page.js +0 -262
  97. package/build/validation/richText.js +0 -744
  98. package/build/validation/tools.js +0 -16
  99. /package/build/{types/index.js → operations/types.js} +0 -0
@@ -1,23 +1,121 @@
1
+ import { z } from "zod";
1
2
  import { server } from "../server/index.js";
2
- import { PAGES_OPERATION_SCHEMA } from "../schema/page.js";
3
- import { BLOCKS_OPERATION_SCHEMA } from "../schema/blocks.js";
4
- import { DATABASE_OPERATION_SCHEMA } from "../schema/database.js";
5
- import { COMMENTS_OPERATION_SCHEMA } from "../schema/comments.js";
6
- import { USERS_OPERATION_SCHEMA } from "../schema/users.js";
7
- import { registerPagesOperationTool } from "./pages.js";
8
- import { registerBlocksOperationTool } from "./blocks.js";
9
- import { registerDatabaseOperationTool } from "./database.js";
10
- import { registerCommentsOperationTool } from "./comments.js";
11
- import { registerUsersOperationTool } from "./users.js";
12
- export const registerAllTools = () => {
13
- // Register combined pages operation tool
14
- server.tool("notion_pages", "Perform various page operations (create, archive, restore, search, update)", PAGES_OPERATION_SCHEMA, registerPagesOperationTool);
15
- // Register combined blocks operation tool
16
- server.tool("notion_blocks", "Perform various block operations (retrieve, update, delete, append children, batch operations)", BLOCKS_OPERATION_SCHEMA, registerBlocksOperationTool);
17
- // Register combined database operation tool
18
- server.tool("notion_database", "Perform various database operations (create, query, update)", DATABASE_OPERATION_SCHEMA, registerDatabaseOperationTool);
19
- // Register combined comments operation tool
20
- server.tool("notion_comments", "Perform various comment operations (get, add to page, add to discussion)", COMMENTS_OPERATION_SCHEMA, registerCommentsOperationTool);
21
- // Register combined users operation tool
22
- server.tool("notion_users", "Perform various user operations (list, get, get bot)", USERS_OPERATION_SCHEMA, registerUsersOperationTool);
3
+ import { initOperations, getOperation, listOperations, operationNames } from "../operations/index.js";
4
+ import { dispatch } from "../dispatch/index.js";
5
+ import { emitJsonSchema } from "../schema/emit.js";
6
+ import { registerAllPrompts } from "../prompts/index.js";
7
+ function jsonContent(value) {
8
+ // Compact JSON keeps the wire response small. Agents parse JSON either way,
9
+ // and the ~30% bloat from indentation isn't worth paying for in every reply.
10
+ const text = typeof value === "string" ? value : JSON.stringify(value);
11
+ return { content: [{ type: "text", text }] };
12
+ }
13
+ function errorContent(value) {
14
+ const text = typeof value === "string" ? value : JSON.stringify(value);
15
+ return { isError: true, content: [{ type: "text", text }] };
16
+ }
17
+ const EXECUTE_INPUT = {
18
+ operation: z
19
+ .string()
20
+ .describe("Operation name. See notion_describe for the schema of any operation, or read the notion://operations resource for the full menu. Common ops: set_page_title, append_blocks, get_page, search_pages, query_database."),
21
+ payload: z
22
+ .record(z.string(), z.unknown())
23
+ .describe("Operation parameters. Pass either single-op fields directly, or { items: [...], atomic?, idempotency_key?, concurrency? } for batch."),
23
24
  };
25
+ const DESCRIBE_INPUT = {
26
+ operation: z.string().describe("Operation name to describe."),
27
+ };
28
+ const EXECUTE_DESCRIPTION = `Execute a Notion operation by name.
29
+
30
+ Two ways to call:
31
+ • Single: { operation: "set_page_title", payload: { page_id, title } }
32
+ • Batch: { operation: "set_page_title", payload: { items: [{page_id, title}, ...], atomic?: false, idempotency_key?: "...", concurrency?: 3 } }
33
+
34
+ If the payload is malformed, the error response includes the full schema + a working example so you can correct and retry in one round-trip. Call notion_describe(operation) ahead of time only for complex shapes (query_database filters, batch_mixed_blocks).
35
+
36
+ Most responses are slimmed by default. Pass verbose:true inside payload (single) or per-item (batch) to get the raw Notion SDK response.`;
37
+ 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.`;
38
+ export async function registerAllTools() {
39
+ await initOperations();
40
+ server.registerTool("notion_execute", {
41
+ title: "Notion Execute",
42
+ description: EXECUTE_DESCRIPTION,
43
+ inputSchema: EXECUTE_INPUT,
44
+ annotations: {
45
+ title: "Notion Execute",
46
+ readOnlyHint: false,
47
+ destructiveHint: true,
48
+ openWorldHint: true,
49
+ },
50
+ }, async ({ operation, payload }) => {
51
+ const result = await dispatch(operation, payload);
52
+ // Batch results (with per-item results) always go back as structured data —
53
+ // a partial success is a normal outcome of the tool, not a tool error.
54
+ const isBatch = typeof result === "object" && result !== null && "summary" in result;
55
+ if (isBatch || result.ok)
56
+ return jsonContent(result);
57
+ return errorContent(result);
58
+ });
59
+ server.registerTool("notion_describe", {
60
+ title: "Notion Describe",
61
+ description: DESCRIBE_DESCRIPTION,
62
+ inputSchema: DESCRIBE_INPUT,
63
+ annotations: {
64
+ title: "Notion Describe",
65
+ readOnlyHint: true,
66
+ destructiveHint: false,
67
+ openWorldHint: false,
68
+ },
69
+ }, async ({ operation }) => {
70
+ const def = getOperation(operation);
71
+ if (!def) {
72
+ return errorContent({
73
+ ok: false,
74
+ error: {
75
+ code: "unknown_operation",
76
+ message: `Unknown operation: "${operation}".`,
77
+ fix: `Available: ${operationNames().join(", ")}`,
78
+ },
79
+ });
80
+ }
81
+ return jsonContent({
82
+ name: def.name,
83
+ description: def.description,
84
+ batchable: def.batchable,
85
+ schema: emitJsonSchema(def.schema),
86
+ example: def.example,
87
+ ...(def.exampleBatch ? { example_batch: def.exampleBatch } : {}),
88
+ });
89
+ });
90
+ // Cheat-sheet resource: a markdown table of every operation
91
+ server.registerResource("operations-index", "notion://operations", {
92
+ title: "Notion operations index",
93
+ description: "Markdown table of every supported operation, batchability, and one-line description.",
94
+ mimeType: "text/markdown",
95
+ }, async () => ({
96
+ contents: [
97
+ {
98
+ uri: "notion://operations",
99
+ mimeType: "text/markdown",
100
+ text: renderOperationsIndex(),
101
+ },
102
+ ],
103
+ }));
104
+ registerAllPrompts();
105
+ }
106
+ function renderOperationsIndex() {
107
+ const lines = [
108
+ "# Notion MCP — Operations",
109
+ "",
110
+ "Call `notion_execute({operation, payload})` with one of these. Use `notion_describe({operation})` for the full schema.",
111
+ "",
112
+ "| Operation | Batchable | Description |",
113
+ "| --- | --- | --- |",
114
+ ];
115
+ for (const def of listOperations()) {
116
+ lines.push(`| \`${def.name}\` | ${def.batchable ? "yes" : "no"} | ${def.description} |`);
117
+ }
118
+ lines.push("", "## `query_database` WHERE DSL", "");
119
+ lines.push("`query_database.where` is a compact DSL 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`.");
120
+ return lines.join("\n");
121
+ }
@@ -1,12 +1,11 @@
1
- import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
1
  import { APIResponseError } from "@notionhq/client";
2
+ import { AuthError } from "../services/auth.js";
3
3
  /**
4
4
  * Error codes from Notion API
5
5
  * @see https://developers.notion.com/reference/status-codes#error-codes
6
6
  */
7
7
  export var NotionErrorCode;
8
8
  (function (NotionErrorCode) {
9
- // 400 errors
10
9
  NotionErrorCode["InvalidJson"] = "invalid_json";
11
10
  NotionErrorCode["InvalidRequestUrl"] = "invalid_request_url";
12
11
  NotionErrorCode["InvalidRequest"] = "invalid_request";
@@ -16,112 +15,152 @@ export var NotionErrorCode;
16
15
  NotionErrorCode["UnsupportedExport"] = "unsupported_export";
17
16
  NotionErrorCode["UnsupportedJsonType"] = "unsupported_json_type";
18
17
  NotionErrorCode["UnsupportedJsonKey"] = "unsupported_json_key";
19
- // 401 errors
20
18
  NotionErrorCode["Unauthorized"] = "unauthorized";
21
19
  NotionErrorCode["InvalidApiKey"] = "invalid_api_key";
22
- // 403 errors
23
20
  NotionErrorCode["RestrictedResource"] = "restricted_resource";
24
21
  NotionErrorCode["InsufficientPermissions"] = "insufficient_permissions";
25
- // 404 errors
26
22
  NotionErrorCode["ObjectNotFound"] = "object_not_found";
27
- // 409 errors
28
23
  NotionErrorCode["ConflictError"] = "conflict_error";
29
24
  NotionErrorCode["AlreadyExists"] = "already_exists";
30
- // 429 errors
31
25
  NotionErrorCode["RateLimited"] = "rate_limited";
32
- // 500 errors
33
26
  NotionErrorCode["InternalServerError"] = "internal_server_error";
34
- // 503 errors
35
27
  NotionErrorCode["ServiceUnavailable"] = "service_unavailable";
28
+ NotionErrorCode["GatewayTimeout"] = "gateway_timeout";
36
29
  NotionErrorCode["DatabaseConnectionUnavailable"] = "database_connection_unavailable";
37
30
  })(NotionErrorCode || (NotionErrorCode = {}));
38
31
  /**
39
- * Map of error messages for specific Notion error codes
32
+ * Codes that indicate a transient failure the request either never ran or
33
+ * the server is asking us to back off. Safe to retry with exponential backoff.
34
+ * Shared by the dispatch retry wrapper and any caller that wants to classify
35
+ * an error envelope without re-encoding the same rules.
40
36
  */
37
+ export const RETRYABLE_NOTION_CODES = new Set([
38
+ NotionErrorCode.RateLimited,
39
+ NotionErrorCode.InternalServerError,
40
+ NotionErrorCode.ServiceUnavailable,
41
+ NotionErrorCode.GatewayTimeout,
42
+ NotionErrorCode.DatabaseConnectionUnavailable,
43
+ ]);
44
+ export function isRetryableNotionCode(code) {
45
+ return code !== undefined && RETRYABLE_NOTION_CODES.has(code);
46
+ }
41
47
  const ERROR_MESSAGES = {
42
- // 400 errors
43
- [NotionErrorCode.InvalidJson]: "The request body could not be decoded as JSON",
44
- [NotionErrorCode.InvalidRequestUrl]: "The request URL is not valid",
45
- [NotionErrorCode.InvalidRequest]: "This request is not supported",
46
- [NotionErrorCode.ValidationError]: "The request body does not match the schema for the expected parameters",
47
- [NotionErrorCode.MissingVersion]: "The request is missing the required Notion-Version header",
48
- [NotionErrorCode.UnsupportedVersion]: "The specified version is not supported",
49
- [NotionErrorCode.UnsupportedExport]: "The specified export type is not supported",
50
- [NotionErrorCode.UnsupportedJsonType]: "The specified JSON type is not supported",
51
- [NotionErrorCode.UnsupportedJsonKey]: "The specified JSON key is not supported",
52
- // 401 errors
53
- [NotionErrorCode.Unauthorized]: "The bearer token is not valid",
54
- [NotionErrorCode.InvalidApiKey]: "The API key is invalid",
55
- // 403 errors
56
- [NotionErrorCode.RestrictedResource]: "The resource is restricted and cannot be accessed with this token",
57
- [NotionErrorCode.InsufficientPermissions]: "The bearer token does not have permission to perform this operation",
58
- // 404 errors
59
- [NotionErrorCode.ObjectNotFound]: "The requested resource does not exist",
60
- // 409 errors
61
- [NotionErrorCode.ConflictError]: "The transaction could not be completed due to a conflict",
62
- [NotionErrorCode.AlreadyExists]: "The resource already exists",
63
- // 429 errors
64
- [NotionErrorCode.RateLimited]: "The request was rate limited. Retry later with exponential backoff",
65
- // 500 errors
66
- [NotionErrorCode.InternalServerError]: "An unexpected error occurred on the Notion servers",
67
- // 503 errors
68
- [NotionErrorCode.ServiceUnavailable]: "The Notion service is unavailable. Retry later with exponential backoff",
69
- [NotionErrorCode.DatabaseConnectionUnavailable]: "The database connection is unavailable. Retry later with exponential backoff",
48
+ [NotionErrorCode.InvalidJson]: {
49
+ message: "The request body could not be decoded as JSON.",
50
+ fix: "Pass the payload as an object, not as a JSON-encoded string.",
51
+ },
52
+ [NotionErrorCode.InvalidRequestUrl]: {
53
+ message: "The request URL is not valid.",
54
+ },
55
+ [NotionErrorCode.InvalidRequest]: {
56
+ message: "This request is not supported.",
57
+ },
58
+ [NotionErrorCode.ValidationError]: {
59
+ message: "The request body does not match the schema for the expected parameters.",
60
+ fix: "Call notion_describe with this operation name to fetch the schema, then retry. Check the 'path' field on the error for the specific bad property.",
61
+ },
62
+ [NotionErrorCode.MissingVersion]: {
63
+ message: "The request is missing the required Notion-Version header.",
64
+ },
65
+ [NotionErrorCode.UnsupportedVersion]: {
66
+ message: "The specified Notion-Version is not supported.",
67
+ },
68
+ [NotionErrorCode.UnsupportedExport]: {
69
+ message: "The specified export type is not supported.",
70
+ },
71
+ [NotionErrorCode.UnsupportedJsonType]: {
72
+ message: "The specified JSON type is not supported.",
73
+ },
74
+ [NotionErrorCode.UnsupportedJsonKey]: {
75
+ message: "The specified JSON key is not supported.",
76
+ },
77
+ [NotionErrorCode.Unauthorized]: {
78
+ message: "The bearer token is not valid.",
79
+ fix: "Set the NOTION_TOKEN environment variable to a valid Notion integration token (starts with `ntn_` or `secret_`).",
80
+ },
81
+ [NotionErrorCode.InvalidApiKey]: {
82
+ message: "The API key is invalid.",
83
+ fix: "Generate a new internal integration token in Notion → Settings → Integrations → My integrations.",
84
+ },
85
+ [NotionErrorCode.RestrictedResource]: {
86
+ message: "The resource is restricted and cannot be accessed with this token.",
87
+ fix: "In Notion, open Settings → Connections → [your integration] → Capabilities and enable the missing scope (e.g. 'Read user information' for /users endpoints, 'Insert content' for block writes). For pages/databases, also confirm the integration is shared with the resource via the page's ••• → Add connections menu.",
88
+ },
89
+ [NotionErrorCode.InsufficientPermissions]: {
90
+ message: "The bearer token does not have permission to perform this operation.",
91
+ fix: "Open the integration in Notion → Settings → Connections and enable the missing capability, then share the target page/database with the integration.",
92
+ },
93
+ [NotionErrorCode.ObjectNotFound]: {
94
+ message: "The requested resource does not exist.",
95
+ fix: "Either the ID is wrong, the integration hasn't been shared with the page/database (use the page's ••• → Add connections menu in Notion), or the object is in trash.",
96
+ },
97
+ [NotionErrorCode.ConflictError]: {
98
+ message: "The transaction could not be completed due to a conflict.",
99
+ fix: "Retry the operation after a short delay.",
100
+ },
101
+ [NotionErrorCode.AlreadyExists]: {
102
+ message: "The resource already exists.",
103
+ },
104
+ [NotionErrorCode.RateLimited]: {
105
+ message: "The request was rate limited.",
106
+ fix: "Retry later with exponential backoff. Notion limits to ~3 requests per second per integration.",
107
+ },
108
+ [NotionErrorCode.InternalServerError]: {
109
+ message: "An unexpected error occurred on the Notion servers.",
110
+ fix: "Retry the operation; if it persists, check status.notion.so.",
111
+ },
112
+ [NotionErrorCode.ServiceUnavailable]: {
113
+ message: "The Notion service is unavailable.",
114
+ fix: "Retry later with exponential backoff.",
115
+ },
116
+ [NotionErrorCode.GatewayTimeout]: {
117
+ message: "The Notion gateway timed out before the request could complete.",
118
+ fix: "Retry later with exponential backoff.",
119
+ },
120
+ [NotionErrorCode.DatabaseConnectionUnavailable]: {
121
+ message: "The database connection is unavailable.",
122
+ fix: "Retry later with exponential backoff.",
123
+ },
70
124
  };
71
- /**
72
- * Get a more descriptive error message for a given Notion error code
73
- */
74
- function getErrorMessage(notionErrorCode, defaultMessage) {
75
- return (ERROR_MESSAGES[notionErrorCode] ||
76
- defaultMessage ||
77
- "An unknown error occurred");
125
+ function lookup(code) {
126
+ return ERROR_MESSAGES[code] ?? { message: "An unknown error occurred." };
78
127
  }
79
- /**
80
- * Handles a Notion API error and returns an appropriate CallToolResult with error details
81
- */
82
- export function handleNotionError(error) {
128
+ export function toErrorEnvelope(error) {
83
129
  if (error instanceof APIResponseError) {
84
- const code = error.code;
85
- const message = getErrorMessage(code, error.message);
86
- // Instead of mapping to specific error codes, just use InternalError as a fallback
130
+ const entry = lookup(error.code);
131
+ const body = error.body;
87
132
  return {
88
- content: [
89
- {
90
- type: "text",
91
- text: `Error: ${message} (${code})`,
92
- },
93
- ],
94
- error: {
95
- code: ErrorCode.InternalError,
96
- message,
97
- },
133
+ code: error.code,
134
+ message: entry.message + (error.message && error.message !== entry.message ? ` (${error.message})` : ""),
135
+ ...(entry.fix ? { fix: entry.fix } : {}),
136
+ ...(String(error.code) === NotionErrorCode.ValidationError && body
137
+ ? { path: extractValidationPath(body) }
138
+ : {}),
98
139
  };
99
140
  }
100
- if (error instanceof Error) {
141
+ if (error instanceof AuthError) {
101
142
  return {
102
- content: [
103
- {
104
- type: "text",
105
- text: `Error: ${error.message}`,
106
- },
107
- ],
108
- error: {
109
- code: ErrorCode.InternalError,
110
- message: error.message,
111
- },
143
+ code: "auth_error",
144
+ message: `Notion auth failed: ${error.message}`,
145
+ fix: "Set NOTION_TOKEN env var, or configure OAuth credentials if using the auth gateway.",
112
146
  };
113
147
  }
114
- // Handle unknown errors
115
- return {
116
- content: [
117
- {
118
- type: "text",
119
- text: `Error: ${String(error)}`,
120
- },
121
- ],
122
- error: {
123
- code: ErrorCode.InternalError,
124
- message: String(error),
125
- },
126
- };
148
+ if (error instanceof Error) {
149
+ return { code: "internal_error", message: error.message };
150
+ }
151
+ return { code: "unknown_error", message: String(error) };
152
+ }
153
+ function extractValidationPath(body) {
154
+ if (typeof body !== "object" || body === null)
155
+ return undefined;
156
+ const maybe = body.details;
157
+ if (!Array.isArray(maybe))
158
+ return undefined;
159
+ const first = maybe[0];
160
+ if (typeof first === "object" &&
161
+ first !== null &&
162
+ Array.isArray(first.path)) {
163
+ return first.path;
164
+ }
165
+ return undefined;
127
166
  }
@@ -0,0 +1,11 @@
1
+ import { toErrorEnvelope } from "./error.js";
2
+ export function tryHandler(fn) {
3
+ return async (params) => {
4
+ try {
5
+ return await fn(params);
6
+ }
7
+ catch (error) {
8
+ return { ok: false, error: toErrorEnvelope(error) };
9
+ }
10
+ };
11
+ }
@@ -0,0 +1,40 @@
1
+ import { emitJsonSchema } from "../schema/emit.js";
2
+ import { sliceJsonSchema, summarizeSchema } from "./schema-slice.js";
3
+ // Top-level .refine() failures (XOR rules etc.) carry the whole-payload
4
+ // example as actionable guidance — the full JSON schema would only add
5
+ // noise. Skip the schema for those and any other top-level-only issue
6
+ // where the example is self-explanatory.
7
+ function shouldIncludeSchema(issues) {
8
+ return issues.some((i) => i.path.length > 0);
9
+ }
10
+ export function buildValidationError(def, zodError) {
11
+ const issues = zodError.issues.map((i) => ({
12
+ path: i.path,
13
+ message: i.message,
14
+ }));
15
+ const firstPath = issues[0]?.path ?? [];
16
+ const firstMsg = issues[0]?.message ?? "Validation failed";
17
+ const includeSchema = shouldIncludeSchema(zodError.issues);
18
+ // Slice the schema down to the failing field's subtree and summarize any
19
+ // unions so the envelope stays small — the unsliced schema for ops like
20
+ // set_page_property or update_database is 5-13KB.
21
+ let schemaForError;
22
+ if (includeSchema) {
23
+ const fullSchema = emitJsonSchema(def.schema);
24
+ const sliced = firstPath.length > 0 ? sliceJsonSchema(fullSchema, firstPath) : fullSchema;
25
+ schemaForError = summarizeSchema(sliced);
26
+ }
27
+ return {
28
+ code: "validation_error",
29
+ operation: def.name,
30
+ message: `${firstMsg}${firstPath.length ? ` at ${firstPath.join(".")}` : ""}`,
31
+ path: firstPath.length ? firstPath : undefined,
32
+ issues,
33
+ ...(includeSchema ? { schema: schemaForError } : {}),
34
+ example: def.example,
35
+ ...(def.exampleBatch ? { example_batch: def.exampleBatch } : {}),
36
+ fix: includeSchema
37
+ ? "Match the example shape. The schema above shows the failing field; call notion_describe for the full operation schema. For batch mode, wrap items in { items: [...] }."
38
+ : "A working example is included above. Match the example shape and retry. For batch mode, wrap items in { items: [...] }.",
39
+ };
40
+ }
@@ -0,0 +1,16 @@
1
+ // Bridge between Zod-validated request shapes and the SDK's strict
2
+ // discriminated-union request types. Zod can describe loose shapes
3
+ // (z.record, z.unknown), but `@notionhq/client` request types use
4
+ // branded discriminated unions that Zod inference can't preserve.
5
+ //
6
+ // Each helper below is a single typed boundary cast: the runtime payload
7
+ // has already been validated by Zod; the cast only tells TypeScript what
8
+ // the SDK expects on the wire. Prefer these over `as never` so the cast
9
+ // target stays visible and grep-able.
10
+ /**
11
+ * Cast a Zod-validated payload to its SDK request shape. The runtime value
12
+ * has already been narrowed by the schema; this only widens the static type.
13
+ */
14
+ export function asSdk(value) {
15
+ return value;
16
+ }
@@ -0,0 +1,35 @@
1
+ // Walks a Notion-style paginated endpoint until the server says has_more=false
2
+ // or we've fetched `limit` pages (whichever comes first). The cap protects
3
+ // against pathologically long walks — the caller surfaces `truncated:true` so
4
+ // the user can re-call with a higher `page_limit` or a narrower query.
5
+ //
6
+ // `limit` is in PAGES, not items: a fetch-page typically returns up to 100
7
+ // items, so the default of 10 pages caps at ~1000 items.
8
+ export const DEFAULT_PAGE_LIMIT = 10;
9
+ export async function paginateAll(fetchPage, opts = {}) {
10
+ const limit = opts.limit ?? DEFAULT_PAGE_LIMIT;
11
+ const merged = [];
12
+ let cursor = undefined;
13
+ let pages_walked = 0;
14
+ let truncated = false;
15
+ while (true) {
16
+ const page = await fetchPage(cursor);
17
+ pages_walked += 1;
18
+ merged.push(...page.results);
19
+ if (!page.has_more)
20
+ break;
21
+ if (!page.next_cursor) {
22
+ // Notion shouldn't return has_more=true with null next_cursor, but if it
23
+ // does we can't continue — surface truncation so the caller can re-query
24
+ // or report the gap rather than silently treating it as complete.
25
+ truncated = true;
26
+ break;
27
+ }
28
+ if (pages_walked >= limit) {
29
+ truncated = true;
30
+ break;
31
+ }
32
+ cursor = page.next_cursor;
33
+ }
34
+ return { results: merged, truncated, pages_walked };
35
+ }