UNPKG

notion-mcp-server

Version:

Notion MCP server with token auth: connect Claude Code, Claude Desktop, Cursor, VS Code, Codex and any MCP client to Notion. Search, create and edit pages, query databases, comment and upload files. Runs headless in CI and agents.

327 lines (326 loc) • 13.6 kB
import { z } from "zod"; import { getOperation } from "../operations/registry.js"; import { begin, complete, release } from "./idempotency.js"; import { mapWithConcurrency } from "./concurrency.js"; import { withCallContext } from "../services/call-context.js"; import { buildValidationError } from "../utils/learning-error.js"; import { partlyAppliedError, toErrorEnvelope } from "../utils/error.js"; import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, } from "../operations/access.js"; const DEFAULT_CONCURRENCY = 3; const MAX_CONCURRENCY = 10; function isBatchPayload(payload) { return (typeof payload === "object" && payload !== null && Array.isArray(payload.items)); } const BATCH_ENVELOPE_KEYS = new Set(["items", "atomic", "idempotency_key", "concurrency"]); /** * The keys a schema accepts at its top level, when it is a plain object * (possibly behind preprocess / optional wrappers). Undefined for anything * else — a loose object, a union — so no warning is ever produced for them. */ function acceptedKeys(schema) { let current = schema; for (let depth = 0; depth < 12; depth++) { const def = current?._zod?.def; if (!def) return undefined; switch (def.type) { case "object": if (def.catchall) return undefined; return Object.keys(def.shape); case "pipe": { // z.preprocess(fn, schema) keeps the object in `out`; schema.transform(fn) in `in`. const out = acceptedKeys(def.out); return out ?? acceptedKeys(def.in); } case "optional": case "nullable": case "default": case "prefault": case "nonoptional": case "readonly": case "catch": current = def.innerType; break; default: return undefined; } } return undefined; } /** * z.object strips keys it does not know, so a misspelt or misplaced field * silently does nothing. Rather than reject the call (an extra round-trip * when everything else was right), run it and say what was ignored. */ function unknownKeyWarning(def, payload) { if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined; const accepted = acceptedKeys(def.schema); if (!accepted) return undefined; const allowed = new Set(accepted); const unknown = Object.keys(payload).filter((k) => !allowed.has(k)); if (unknown.length === 0) return undefined; const list = unknown.map((k) => `"${k}"`).join(", "); return `Ignored unknown field${unknown.length > 1 ? "s" : ""} ${list}. ${def.name} accepts: ${accepted.join(", ")}.`; } function withWarning(result, warning) { if (!warning || !result.ok) return result; const existing = result.warnings ?? []; return { ...result, warnings: [warning, ...existing] }; } function unknownOperationError(name) { return { code: "unknown_operation", message: `Unknown operation: "${name}". Use notion_describe with a valid operation name, or check the notion://operations resource for the available list.`, fix: `Available operations: ${enabledOperationNames().join(", ")}`, }; } export async function dispatch(operationName, payload) { const def = getOperation(operationName); if (!def) { return { ok: false, error: unknownOperationError(operationName) }; } if (!isOperationAllowed(operationName)) { return { ok: false, error: operationNotAllowedError(operationName) }; } if (isBatchPayload(payload)) { if (!def.batchable) { // batch_mixed_blocks looks batch-shaped but uses its own `operations[]` // envelope (mixed op kinds, no per-item rollback). Point callers at the // right shape instead of the generic not_batchable message. if (operationName === "batch_mixed_blocks") { return { ok: false, error: { code: "wrong_envelope", message: 'batch_mixed_blocks uses its own envelope: { operations: [{ op: "append"|"update"|"delete", ... }] }. The universal { items: [...] } envelope does not apply here.', fix: 'Wrap your operations as { operations: [{ op: "append", block_id, markdown }, { op: "update", ... }, { op: "delete", ... }] }. Or use the items[] form on append_blocks / update_block / delete_block for single-kind batches.', }, }; } return { ok: false, error: { code: "not_batchable", message: `Operation "${operationName}" does not support batch mode.`, fix: "Call it with a single payload object instead of { items: [...] }.", }, }; } return runBatch(def, payload); } return runSingle(def, payload); } // Run the handler once. Each Notion request it sends is paced and retried on // its own (PacedClient in services/notion.ts), so the handler is never run // again. A write that fails after one of its requests changed something in // Notion says so in its error, so the model does not repeat the whole call. function runHandlerWithLimitAndRetry(def, params) { return withCallContext(def.access, async (call) => { const partly = () => def.access === "write" && call.wrote; try { const result = await def.handler(params); if (result.ok || !partly()) return result; return { ...result, error: partlyAppliedError(result.error) }; } catch (error) { if (!partly()) throw error; return { ok: false, error: partlyAppliedError(toErrorEnvelope(error)) }; } }); } async function runSingle(def, payload) { const parsed = def.schema.safeParse(payload); if (!parsed.success) { return { ok: false, error: buildValidationError(def, parsed.error) }; } try { const result = await runHandlerWithLimitAndRetry(def, parsed.data); return withWarning(result, unknownKeyWarning(def, payload)); } catch (error) { return { ok: false, error: toErrorEnvelope(error) }; } } async function runBatch(def, payload) { const atomic = payload.atomic === true; const rawIdempotencyKey = payload.idempotency_key; // Clients that fill every optional field send null for "no key"; before // keys were validated, null meant exactly that. if (rawIdempotencyKey == null) return (await runItems(def, payload, atomic)).result; if (typeof rawIdempotencyKey !== "string" || rawIdempotencyKey.trim() === "") { return { ok: false, error: { code: "invalid_idempotency_key", message: "idempotency_key must be a non-empty string.", fix: "Use a stable non-empty string for retries of the same batch, or omit idempotency_key.", }, }; } const admission = begin(def.name, rawIdempotencyKey, payload.items, atomic); if (admission.action === "deduplicated") { return { ...admission.result, idempotency_receipt: { decision: "deduplicated", replay_of: "original" }, }; } if (admission.action === "conflict") { return { ok: false, error: { code: "idempotency_conflict", message: "The idempotency key is already bound to a different operation, items payload, or atomic mode.", fix: "Retry the original request unchanged, or use a new idempotency key for a different mutation.", }, }; } if (admission.action === "pending") { return { ok: false, error: { code: "idempotency_in_progress", message: "A request with this idempotency key and payload is already in progress.", fix: "Wait for the in-flight request to finish, then retry the same request.", }, }; } // Until complete() runs, the key is pending and blocks every retry; release // it on any other way out so a throw or an all-invalid batch can be retried. let completed = false; try { const { result, ran } = await runItems(def, payload, atomic); // No item reached Notion (every one failed validation): there is no side // effect to protect, and caching the failure would make the corrected // retry need a new key. if (!ran) return result; const acceptedResult = { ...result, idempotency_receipt: { decision: "accepted" }, }; complete(admission.key, admission.fingerprint, acceptedResult); completed = true; return acceptedResult; } finally { if (!completed) release(admission.key, admission.fingerprint); } } /** Run a batch's items; `ran` says whether any of them reached its handler. */ async function runItems(def, payload, atomic) { // Atomic mode requires serial execution: with concurrency > 1, the `aborted` // flag is set only after the first failure resolves, but other workers have // already started in-flight requests, so later items execute when they // shouldn't. Force concurrency=1 to make the abort barrier reliable. const requested = payload.concurrency ?? DEFAULT_CONCURRENCY; const concurrency = atomic ? 1 : Math.max(1, Math.min(requested, MAX_CONCURRENCY)); const items = payload.items; const createdForRollback = []; let aborted = false; let ran = false; const results = await mapWithConcurrency(items, concurrency, async (item, index) => { if (aborted) { return { index, ok: false, error: { code: "aborted", message: "Skipped: a prior item failed in atomic batch.", }, }; } const parsed = def.schema.safeParse(item); if (!parsed.success) { const failure = { index, ok: false, error: buildValidationError(def, parsed.error), }; if (atomic) aborted = true; return failure; } ran = true; try { const result = await runHandlerWithLimitAndRetry(def, parsed.data); if (result.ok) { const warning = unknownKeyWarning(def, item); const warnings = [...(warning ? [warning] : []), ...(result.warnings ?? [])]; const success = { index, ok: true, data: result.data, ...(warnings.length ? { warnings } : {}), }; if (atomic && def.rollback) createdForRollback.push({ item: success }); return success; } const failure = { index, ok: false, error: result.error, }; if (atomic) aborted = true; return failure; } catch (error) { const failure = { index, ok: false, error: toErrorEnvelope(error), }; if (atomic) aborted = true; return failure; } }); const succeeded = results.filter((r) => r.ok).length; const failed = results.length - succeeded; let rolledBack; if (atomic && failed > 0 && def.rollback && createdForRollback.length > 0) { rolledBack = 0; for (const { item } of createdForRollback) { if (!item.ok) continue; try { await def.rollback(item.data); rolledBack++; } catch { // best-effort: swallow rollback errors so we still return the original failure } } } const envelopeUnknown = Object.keys(payload).filter((k) => !BATCH_ENVELOPE_KEYS.has(k)); const batchResult = { ok: failed === 0, summary: { total: results.length, succeeded, failed }, results, ...(rolledBack !== undefined ? { rolled_back: rolledBack } : {}), ...(envelopeUnknown.length ? { warnings: [ `Ignored unknown batch field${envelopeUnknown.length > 1 ? "s" : ""} ${envelopeUnknown.map((k) => `"${k}"`).join(", ")}. A batch payload accepts: items, atomic, concurrency, idempotency_key.`, ], } : {}), }; return { result: batchResult, ran }; } export const BATCH_ENVELOPE_HELP = `Batch mode: pass { items: [...], atomic?: boolean, idempotency_key?: string, concurrency?: 1-10 }. Each item is validated independently; failures are reported per-item. atomic:true forces serial execution (concurrency=1) and triggers best-effort rollback of created entities on first failure; subsequent items are skipped with code:"aborted".`; export const _internal = { isBatchPayload, acceptedKeys, unknownKeyWarning }; // Re-export Zod for downstream operation files to share a single version export { z };