From b778ad26141ee399869c11702ce02bd409ac878a Mon Sep 17 00:00:00 2001 From: Brandon Bennett <107384180+branben@users.noreply.github.com> Date: Sat, 30 May 2026 20:17:56 -0400 Subject: [PATCH] feat(notion): add Notion MCP context source with 6 tools, dashboard tab, and 20 tests (#2959) Integrated into release/v3.8.8 --- .source/browser.ts | 2 +- .source/server.ts | 58 ++-- CHANGELOG.md | 8 + docs/frameworks/MCP-SERVER.md | 87 ++++-- open-sse/mcp-server/server.ts | 31 ++- open-sse/mcp-server/tools/notionTools.ts | 106 ++++++++ .../dashboard/endpoint/EndpointPageClient.tsx | 9 +- .../endpoint/components/NotionSourceCard.tsx | 177 +++++++++++++ src/app/api/settings/notion/route.ts | 89 +++++++ src/lib/db/notion.ts | 48 ++++ src/lib/notion/api.ts | 249 ++++++++++++++++++ tests/unit/db/notion.test.mjs | 25 ++ tests/unit/notion-api.test.ts | 54 ++++ tests/unit/notion-tools.test.ts | 61 +++++ 14 files changed, 950 insertions(+), 54 deletions(-) create mode 100644 open-sse/mcp-server/tools/notionTools.ts create mode 100644 src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx create mode 100644 src/app/api/settings/notion/route.ts create mode 100644 src/lib/db/notion.ts create mode 100644 src/lib/notion/api.ts create mode 100644 tests/unit/db/notion.test.mjs create mode 100644 tests/unit/notion-api.test.ts create mode 100644 tests/unit/notion-tools.test.ts diff --git a/.source/browser.ts b/.source/browser.ts index ce5576f0bb..006dbe3f12 100644 --- a/.source/browser.ts +++ b/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), + docs: create.doc("docs", {"architecture/ARCHITECTURE.md": () => import("../docs/architecture/ARCHITECTURE.md?collection=docs"), "architecture/AUTHZ_GUIDE.md": () => import("../docs/architecture/AUTHZ_GUIDE.md?collection=docs"), "architecture/CODEBASE_DOCUMENTATION.md": () => import("../docs/architecture/CODEBASE_DOCUMENTATION.md?collection=docs"), "architecture/REPOSITORY_MAP.md": () => import("../docs/architecture/REPOSITORY_MAP.md?collection=docs"), "architecture/RESILIENCE_GUIDE.md": () => import("../docs/architecture/RESILIENCE_GUIDE.md?collection=docs"), "compression/COMPRESSION_ENGINES.md": () => import("../docs/compression/COMPRESSION_ENGINES.md?collection=docs"), "compression/COMPRESSION_GUIDE.md": () => import("../docs/compression/COMPRESSION_GUIDE.md?collection=docs"), "compression/COMPRESSION_LANGUAGE_PACKS.md": () => import("../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs"), "compression/COMPRESSION_RULES_FORMAT.md": () => import("../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs"), "compression/RTK_COMPRESSION.md": () => import("../docs/compression/RTK_COMPRESSION.md?collection=docs"), "frameworks/A2A-SERVER.md": () => import("../docs/frameworks/A2A-SERVER.md?collection=docs"), "frameworks/AGENT_PROTOCOLS_GUIDE.md": () => import("../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs"), "frameworks/CLOUD_AGENT.md": () => import("../docs/frameworks/CLOUD_AGENT.md?collection=docs"), "frameworks/EMBEDDED-SERVICES.md": () => import("../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs"), "frameworks/EVALS.md": () => import("../docs/frameworks/EVALS.md?collection=docs"), "frameworks/GAMIFICATION.md": () => import("../docs/frameworks/GAMIFICATION.md?collection=docs"), "frameworks/MCP-SERVER.md": () => import("../docs/frameworks/MCP-SERVER.md?collection=docs"), "frameworks/MEMORY.md": () => import("../docs/frameworks/MEMORY.md?collection=docs"), "frameworks/OPENCODE.md": () => import("../docs/frameworks/OPENCODE.md?collection=docs"), "frameworks/SKILLS.md": () => import("../docs/frameworks/SKILLS.md?collection=docs"), "frameworks/WEBHOOKS.md": () => import("../docs/frameworks/WEBHOOKS.md?collection=docs"), "guides/DOCKER_GUIDE.md": () => import("../docs/guides/DOCKER_GUIDE.md?collection=docs"), "guides/ELECTRON_GUIDE.md": () => import("../docs/guides/ELECTRON_GUIDE.md?collection=docs"), "guides/FEATURES.md": () => import("../docs/guides/FEATURES.md?collection=docs"), "guides/I18N.md": () => import("../docs/guides/I18N.md?collection=docs"), "guides/KIRO_SETUP.md": () => import("../docs/guides/KIRO_SETUP.md?collection=docs"), "guides/PWA_GUIDE.md": () => import("../docs/guides/PWA_GUIDE.md?collection=docs"), "guides/SETUP_GUIDE.md": () => import("../docs/guides/SETUP_GUIDE.md?collection=docs"), "guides/TERMUX_GUIDE.md": () => import("../docs/guides/TERMUX_GUIDE.md?collection=docs"), "guides/TROUBLESHOOTING.md": () => import("../docs/guides/TROUBLESHOOTING.md?collection=docs"), "guides/UNINSTALL.md": () => import("../docs/guides/UNINSTALL.md?collection=docs"), "guides/USER_GUIDE.md": () => import("../docs/guides/USER_GUIDE.md?collection=docs"), "ops/COVERAGE_PLAN.md": () => import("../docs/ops/COVERAGE_PLAN.md?collection=docs"), "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": () => import("../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs"), "ops/FLY_IO_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs"), "ops/PROXY_GUIDE.md": () => import("../docs/ops/PROXY_GUIDE.md?collection=docs"), "ops/RELEASE_CHECKLIST.md": () => import("../docs/ops/RELEASE_CHECKLIST.md?collection=docs"), "ops/SQLITE_RUNTIME.md": () => import("../docs/ops/SQLITE_RUNTIME.md?collection=docs"), "ops/TUNNELS_GUIDE.md": () => import("../docs/ops/TUNNELS_GUIDE.md?collection=docs"), "ops/VM_DEPLOYMENT_GUIDE.md": () => import("../docs/ops/VM_DEPLOYMENT_GUIDE.md?collection=docs"), "reference/API_REFERENCE.md": () => import("../docs/reference/API_REFERENCE.md?collection=docs"), "reference/CLI-TOOLS.md": () => import("../docs/reference/CLI-TOOLS.md?collection=docs"), "reference/ENVIRONMENT.md": () => import("../docs/reference/ENVIRONMENT.md?collection=docs"), "reference/FREE_TIERS.md": () => import("../docs/reference/FREE_TIERS.md?collection=docs"), "reference/PROVIDER_REFERENCE.md": () => import("../docs/reference/PROVIDER_REFERENCE.md?collection=docs"), "routing/AUTO-COMBO.md": () => import("../docs/routing/AUTO-COMBO.md?collection=docs"), "routing/REASONING_REPLAY.md": () => import("../docs/routing/REASONING_REPLAY.md?collection=docs"), "security/CLI_TOKEN.md": () => import("../docs/security/CLI_TOKEN.md?collection=docs"), "security/CLI_TOKEN_AUTH.md": () => import("../docs/security/CLI_TOKEN_AUTH.md?collection=docs"), "security/COMPLIANCE.md": () => import("../docs/security/COMPLIANCE.md?collection=docs"), "security/ERROR_SANITIZATION.md": () => import("../docs/security/ERROR_SANITIZATION.md?collection=docs"), "security/GUARDRAILS.md": () => import("../docs/security/GUARDRAILS.md?collection=docs"), "security/PUBLIC_CREDS.md": () => import("../docs/security/PUBLIC_CREDS.md?collection=docs"), "security/ROUTE_GUARD_TIERS.md": () => import("../docs/security/ROUTE_GUARD_TIERS.md?collection=docs"), "security/SOCKET_DEV_FINDINGS.md": () => import("../docs/security/SOCKET_DEV_FINDINGS.md?collection=docs"), "security/STEALTH_GUIDE.md": () => import("../docs/security/STEALTH_GUIDE.md?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/.source/server.ts b/.source/server.ts index b7e781f56b..9debc21e3a 100644 --- a/.source/server.ts +++ b/.source/server.ts @@ -1,10 +1,10 @@ // @ts-nocheck -import { default as __fd_glob_65 } from "../docs/security/meta.json?collection=docs" -import { default as __fd_glob_64 } from "../docs/routing/meta.json?collection=docs" -import { default as __fd_glob_63 } from "../docs/reference/openapi.yaml?collection=docs" -import { default as __fd_glob_62 } from "../docs/reference/meta.json?collection=docs" -import { default as __fd_glob_61 } from "../docs/ops/meta.json?collection=docs" -import { default as __fd_glob_60 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_65 } from "../docs/reference/openapi.yaml?collection=docs" +import { default as __fd_glob_64 } from "../docs/reference/meta.json?collection=docs" +import { default as __fd_glob_63 } from "../docs/security/meta.json?collection=docs" +import { default as __fd_glob_62 } from "../docs/routing/meta.json?collection=docs" +import { default as __fd_glob_61 } from "../docs/guides/meta.json?collection=docs" +import { default as __fd_glob_60 } from "../docs/ops/meta.json?collection=docs" import { default as __fd_glob_59 } from "../docs/frameworks/meta.json?collection=docs" import { default as __fd_glob_58 } from "../docs/compression/meta.json?collection=docs" import { default as __fd_glob_57 } from "../docs/architecture/meta.json?collection=docs" @@ -33,28 +33,28 @@ import * as __fd_glob_35 from "../docs/ops/PROXY_GUIDE.md?collection=docs" import * as __fd_glob_34 from "../docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md?collection=docs" import * as __fd_glob_33 from "../docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md?collection=docs" import * as __fd_glob_32 from "../docs/ops/COVERAGE_PLAN.md?collection=docs" -import * as __fd_glob_31 from "../docs/frameworks/WEBHOOKS.md?collection=docs" -import * as __fd_glob_30 from "../docs/frameworks/SKILLS.md?collection=docs" -import * as __fd_glob_29 from "../docs/frameworks/OPENCODE.md?collection=docs" -import * as __fd_glob_28 from "../docs/frameworks/MEMORY.md?collection=docs" -import * as __fd_glob_27 from "../docs/frameworks/MCP-SERVER.md?collection=docs" -import * as __fd_glob_26 from "../docs/frameworks/GAMIFICATION.md?collection=docs" -import * as __fd_glob_25 from "../docs/frameworks/EVALS.md?collection=docs" -import * as __fd_glob_24 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" -import * as __fd_glob_23 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" -import * as __fd_glob_22 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" -import * as __fd_glob_21 from "../docs/frameworks/A2A-SERVER.md?collection=docs" -import * as __fd_glob_20 from "../docs/guides/USER_GUIDE.md?collection=docs" -import * as __fd_glob_19 from "../docs/guides/UNINSTALL.md?collection=docs" -import * as __fd_glob_18 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" -import * as __fd_glob_17 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" -import * as __fd_glob_16 from "../docs/guides/SETUP_GUIDE.md?collection=docs" -import * as __fd_glob_15 from "../docs/guides/PWA_GUIDE.md?collection=docs" -import * as __fd_glob_14 from "../docs/guides/KIRO_SETUP.md?collection=docs" -import * as __fd_glob_13 from "../docs/guides/I18N.md?collection=docs" -import * as __fd_glob_12 from "../docs/guides/FEATURES.md?collection=docs" -import * as __fd_glob_11 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" -import * as __fd_glob_10 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_31 from "../docs/guides/USER_GUIDE.md?collection=docs" +import * as __fd_glob_30 from "../docs/guides/UNINSTALL.md?collection=docs" +import * as __fd_glob_29 from "../docs/guides/TROUBLESHOOTING.md?collection=docs" +import * as __fd_glob_28 from "../docs/guides/TERMUX_GUIDE.md?collection=docs" +import * as __fd_glob_27 from "../docs/guides/SETUP_GUIDE.md?collection=docs" +import * as __fd_glob_26 from "../docs/guides/PWA_GUIDE.md?collection=docs" +import * as __fd_glob_25 from "../docs/guides/KIRO_SETUP.md?collection=docs" +import * as __fd_glob_24 from "../docs/guides/I18N.md?collection=docs" +import * as __fd_glob_23 from "../docs/guides/FEATURES.md?collection=docs" +import * as __fd_glob_22 from "../docs/guides/ELECTRON_GUIDE.md?collection=docs" +import * as __fd_glob_21 from "../docs/guides/DOCKER_GUIDE.md?collection=docs" +import * as __fd_glob_20 from "../docs/frameworks/WEBHOOKS.md?collection=docs" +import * as __fd_glob_19 from "../docs/frameworks/SKILLS.md?collection=docs" +import * as __fd_glob_18 from "../docs/frameworks/OPENCODE.md?collection=docs" +import * as __fd_glob_17 from "../docs/frameworks/MEMORY.md?collection=docs" +import * as __fd_glob_16 from "../docs/frameworks/MCP-SERVER.md?collection=docs" +import * as __fd_glob_15 from "../docs/frameworks/GAMIFICATION.md?collection=docs" +import * as __fd_glob_14 from "../docs/frameworks/EVALS.md?collection=docs" +import * as __fd_glob_13 from "../docs/frameworks/EMBEDDED-SERVICES.md?collection=docs" +import * as __fd_glob_12 from "../docs/frameworks/CLOUD_AGENT.md?collection=docs" +import * as __fd_glob_11 from "../docs/frameworks/AGENT_PROTOCOLS_GUIDE.md?collection=docs" +import * as __fd_glob_10 from "../docs/frameworks/A2A-SERVER.md?collection=docs" import * as __fd_glob_9 from "../docs/compression/RTK_COMPRESSION.md?collection=docs" import * as __fd_glob_8 from "../docs/compression/COMPRESSION_RULES_FORMAT.md?collection=docs" import * as __fd_glob_7 from "../docs/compression/COMPRESSION_LANGUAGE_PACKS.md?collection=docs" @@ -73,4 +73,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "guides/meta.json": __fd_glob_60, "ops/meta.json": __fd_glob_61, "reference/meta.json": __fd_glob_62, "reference/openapi.yaml": __fd_glob_63, "routing/meta.json": __fd_glob_64, "security/meta.json": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "guides/DOCKER_GUIDE.md": __fd_glob_10, "guides/ELECTRON_GUIDE.md": __fd_glob_11, "guides/FEATURES.md": __fd_glob_12, "guides/I18N.md": __fd_glob_13, "guides/KIRO_SETUP.md": __fd_glob_14, "guides/PWA_GUIDE.md": __fd_glob_15, "guides/SETUP_GUIDE.md": __fd_glob_16, "guides/TERMUX_GUIDE.md": __fd_glob_17, "guides/TROUBLESHOOTING.md": __fd_glob_18, "guides/UNINSTALL.md": __fd_glob_19, "guides/USER_GUIDE.md": __fd_glob_20, "frameworks/A2A-SERVER.md": __fd_glob_21, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_22, "frameworks/CLOUD_AGENT.md": __fd_glob_23, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_24, "frameworks/EVALS.md": __fd_glob_25, "frameworks/GAMIFICATION.md": __fd_glob_26, "frameworks/MCP-SERVER.md": __fd_glob_27, "frameworks/MEMORY.md": __fd_glob_28, "frameworks/OPENCODE.md": __fd_glob_29, "frameworks/SKILLS.md": __fd_glob_30, "frameworks/WEBHOOKS.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file +export const docs = await create.docs("docs", "docs", {"meta.json": __fd_glob_56, "architecture/meta.json": __fd_glob_57, "compression/meta.json": __fd_glob_58, "frameworks/meta.json": __fd_glob_59, "ops/meta.json": __fd_glob_60, "guides/meta.json": __fd_glob_61, "routing/meta.json": __fd_glob_62, "security/meta.json": __fd_glob_63, "reference/meta.json": __fd_glob_64, "reference/openapi.yaml": __fd_glob_65, }, {"architecture/ARCHITECTURE.md": __fd_glob_0, "architecture/AUTHZ_GUIDE.md": __fd_glob_1, "architecture/CODEBASE_DOCUMENTATION.md": __fd_glob_2, "architecture/REPOSITORY_MAP.md": __fd_glob_3, "architecture/RESILIENCE_GUIDE.md": __fd_glob_4, "compression/COMPRESSION_ENGINES.md": __fd_glob_5, "compression/COMPRESSION_GUIDE.md": __fd_glob_6, "compression/COMPRESSION_LANGUAGE_PACKS.md": __fd_glob_7, "compression/COMPRESSION_RULES_FORMAT.md": __fd_glob_8, "compression/RTK_COMPRESSION.md": __fd_glob_9, "frameworks/A2A-SERVER.md": __fd_glob_10, "frameworks/AGENT_PROTOCOLS_GUIDE.md": __fd_glob_11, "frameworks/CLOUD_AGENT.md": __fd_glob_12, "frameworks/EMBEDDED-SERVICES.md": __fd_glob_13, "frameworks/EVALS.md": __fd_glob_14, "frameworks/GAMIFICATION.md": __fd_glob_15, "frameworks/MCP-SERVER.md": __fd_glob_16, "frameworks/MEMORY.md": __fd_glob_17, "frameworks/OPENCODE.md": __fd_glob_18, "frameworks/SKILLS.md": __fd_glob_19, "frameworks/WEBHOOKS.md": __fd_glob_20, "guides/DOCKER_GUIDE.md": __fd_glob_21, "guides/ELECTRON_GUIDE.md": __fd_glob_22, "guides/FEATURES.md": __fd_glob_23, "guides/I18N.md": __fd_glob_24, "guides/KIRO_SETUP.md": __fd_glob_25, "guides/PWA_GUIDE.md": __fd_glob_26, "guides/SETUP_GUIDE.md": __fd_glob_27, "guides/TERMUX_GUIDE.md": __fd_glob_28, "guides/TROUBLESHOOTING.md": __fd_glob_29, "guides/UNINSTALL.md": __fd_glob_30, "guides/USER_GUIDE.md": __fd_glob_31, "ops/COVERAGE_PLAN.md": __fd_glob_32, "ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md": __fd_glob_33, "ops/FLY_IO_DEPLOYMENT_GUIDE.md": __fd_glob_34, "ops/PROXY_GUIDE.md": __fd_glob_35, "ops/RELEASE_CHECKLIST.md": __fd_glob_36, "ops/SQLITE_RUNTIME.md": __fd_glob_37, "ops/TUNNELS_GUIDE.md": __fd_glob_38, "ops/VM_DEPLOYMENT_GUIDE.md": __fd_glob_39, "reference/API_REFERENCE.md": __fd_glob_40, "reference/CLI-TOOLS.md": __fd_glob_41, "reference/ENVIRONMENT.md": __fd_glob_42, "reference/FREE_TIERS.md": __fd_glob_43, "reference/PROVIDER_REFERENCE.md": __fd_glob_44, "routing/AUTO-COMBO.md": __fd_glob_45, "routing/REASONING_REPLAY.md": __fd_glob_46, "security/CLI_TOKEN.md": __fd_glob_47, "security/CLI_TOKEN_AUTH.md": __fd_glob_48, "security/COMPLIANCE.md": __fd_glob_49, "security/ERROR_SANITIZATION.md": __fd_glob_50, "security/GUARDRAILS.md": __fd_glob_51, "security/PUBLIC_CREDS.md": __fd_glob_52, "security/ROUTE_GUARD_TIERS.md": __fd_glob_53, "security/SOCKET_DEV_FINDINGS.md": __fd_glob_54, "security/STEALTH_GUIDE.md": __fd_glob_55, }); \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a114d775a2..239006f3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + --- ## [3.8.7] — 2026-05-29 diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 1dae671b76..3c7b0f51fb 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,18 +1,18 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.8 +lastUpdated: 2026-05-30 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations. +> Model Context Protocol server with 43 tools across routing, cache, compression, memory, skills, proxy, and context source operations. > -> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. +> Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. -![MCP tool inventory (37 tools by category)](../diagrams/exported/mcp-tools-37.svg) +![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-43.svg) -> Source: [diagrams/mcp-tools-37.mmd](../diagrams/mcp-tools-37.mmd) +> Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (update from `mcp-tools-37` when regenerating) ## Installation @@ -158,27 +158,55 @@ the runtime compression model behind these tools. Defined in `open-sse/mcp-server/tools/memoryTools.ts`. Auth/scope is enforced through the standard MCP scope pipeline. -| Tool | Description | -| :------------------------ | :---------------------------------------------------------------------------------- | -| `omniroute_memory_search` | Search memories by query / type / API key with token-budget enforcement | -| `omniroute_memory_add` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | -| `omniroute_memory_clear` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | +| Tool | Scopes | Description | +| :------------------------ | :--------------- | :---------------------------------------------------------------------------------- | +| `omniroute_memory_search` | `read:memory` | Search memories by query / type / API key with token-budget enforcement | +| `omniroute_memory_add` | `write:memory` | Add a new memory entry (`factual` / `episodic` / `procedural` / `semantic`) | +| `omniroute_memory_clear` | `write:memory` | Clear memories for an API key, optionally filtered by type or `olderThan` timestamp | ## Skill Tools (4) Defined in `open-sse/mcp-server/tools/skillTools.ts`. Backed by `src/lib/skills/registry` + `src/lib/skills/executor`. -| Tool | Description | -| :---------------------------- | :-------------------------------------------------------------------------------- | -| `omniroute_skills_list` | List registered skills with optional filtering by API key, name, or enabled state | -| `omniroute_skills_enable` | Enable or disable a specific skill by ID | -| `omniroute_skills_execute` | Execute a skill with provided input and return the execution record | -| `omniroute_skills_executions` | List recent skill execution history | +| Tool | Scopes | Description | +| :---------------------------- | :-------------- | :-------------------------------------------------------------------------------- | +| `omniroute_skills_list` | `read:skills` | List registered skills with optional filtering by API key, name, or enabled state | +| `omniroute_skills_enable` | `write:skills` | Enable or disable a specific skill by ID | +| `omniroute_skills_execute` | `execute:skills`| Execute a skill with provided input and return the execution record | +| `omniroute_skills_executions` | `read:skills` | List recent skill execution history | + +## Notion Context Source (6) + +Defined in `open-sse/mcp-server/tools/notionTools.ts`. Token stored in `key_value` table via `src/lib/db/notion.ts`. REST client in `src/lib/notion/api.ts`. Settings API in `src/app/api/settings/notion/route.ts`. Dashboard UI in `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx`. + +Configure your Notion integration token from the **Context Sources** tab in the Endpoint dashboard, or via the REST API: + +```bash +# Set token +curl -X POST http://localhost:20128/api/settings/notion \ + -H "Content-Type: application/json" \ + -d '{"token": "ntn_..."}' + +# Check status +curl http://localhost:20128/api/settings/notion + +# Disconnect +curl -X DELETE http://localhost:20128/api/settings/notion +``` + +| Tool | Scopes | Description | +| :--------------------------- | :--------------- | :------------------------------------------------------------------------------------ | +| `omniroute_notion_search` | `read:notion` | Full-text search across all pages and databases | +| `omniroute_notion_list_databases` | `read:notion` | List all accessible databases with schema metadata | +| `omniroute_notion_get_database` | `read:notion` | Get database schema by ID | +| `omniroute_notion_query_database` | `read:notion` | Query a database with filters, sorts, and pagination | +| `omniroute_notion_read` | `read:notion` | Read a page or block by ID with its content | +| `omniroute_notion_append_blocks` | `write:notion`| Append children blocks to a parent block (max 100 per request) | ## Related Frameworks (v3.8.0) -The MCP tool inventory above (37 tools = 30 base + 3 memory + 4 skills) is intentionally -scoped to runtime routing/cache/compression/memory/skills/proxy operations. Two adjacent +The MCP tool inventory above (43 tools = 30 core + 3 memory + 4 skills + 6 notion) is intentionally +scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: ### Cloud Agents @@ -247,11 +275,16 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central | `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:compression` | `compression_configure`, `set_compression_engine` | | `read:proxies` | `oneproxy_fetch`, `oneproxy_rotate`, `oneproxy_stats` | +| `read:notion` | `notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read` | +| `write:notion` | `notion_append_blocks` | +| `read:memory` | `memory_search` | +| `write:memory` | `memory_add`, `memory_clear` | +| `read:skills` | `skills_list`, `skills_executions` | +| `write:skills` | `skills_enable` | +| `execute:skills` | `skills_execute` | Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access. -Memory and Skill tools currently do not declare static scope requirements in their definitions; access is gated by the caller's API key and audited through the standard MCP audit pipeline. - --- ## Environment Variables @@ -294,7 +327,7 @@ The heartbeat snapshot contains: "transport": "stdio", "scopesEnforced": false, "allowedScopes": [], - "toolCount": 37 + "toolCount": 43 } ``` @@ -328,9 +361,19 @@ Use the dashboard or the `/api/mcp/audit` and `/api/mcp/audit/stats` REST endpoi | `open-sse/mcp-server/tools/compressionTools.ts` | Compression tool handlers | | `open-sse/mcp-server/tools/memoryTools.ts` | Memory tool definitions (3 tools) | | `open-sse/mcp-server/tools/skillTools.ts` | Skill tool definitions (4 tools) | +| `open-sse/mcp-server/tools/notionTools.ts` | Notion context source tool definitions (6 tools) | +| `open-sse/mcp-server/tools/gamificationTools.ts`| Gamification tool definitions (8 tools) | +| `open-sse/mcp-server/tools/pluginTools.ts` | Plugin registration and management tools (8 tools) | | `src/app/api/mcp/status/route.ts` | `/api/mcp/status` endpoint | | `src/app/api/mcp/tools/route.ts` | `/api/mcp/tools` endpoint | | `src/app/api/mcp/sse/route.ts` | `/api/mcp/sse` SSE transport route | | `src/app/api/mcp/stream/route.ts` | `/api/mcp/stream` Streamable HTTP transport route | | `src/app/api/mcp/audit/route.ts` | `/api/mcp/audit` audit log query | | `src/app/api/mcp/audit/stats/route.ts` | `/api/mcp/audit/stats` aggregated audit metrics | +| `src/lib/notion/api.ts` | Notion REST API client (retry, timeout, error classification) | +| `src/lib/db/notion.ts` | Notion token persistence (`key_value` table) | +| `src/app/api/settings/notion/route.ts` | Notion settings API (GET/POST/DELETE) | +| `src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx` | Notion token management UI | +| `tests/unit/notion-api.test.ts` | Notion API client tests (7) | +| `tests/unit/notion-tools.test.ts` | Notion tools scope enforcement tests (10) | +| `tests/unit/db/notion.test.mjs` | Notion DB module tests (3) | diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7b75448745..6df518f58a 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -78,6 +78,7 @@ import { skillTools } from "./tools/skillTools.ts"; import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; +import { notionTools } from "./tools/notionTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts"; import { @@ -104,7 +105,8 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(memoryTools).length + Object.keys(skillTools).length + gamificationTools.length + - pluginTools.length; + pluginTools.length + + notionTools.length; type JsonRecord = Record; @@ -1074,6 +1076,33 @@ export function createMcpServer(): McpServer { ); }); + // ── Notion Context Source Tools ─────────────── + notionTools.forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) + ); + }); + return server; } diff --git a/open-sse/mcp-server/tools/notionTools.ts b/open-sse/mcp-server/tools/notionTools.ts new file mode 100644 index 0000000000..2415482a94 --- /dev/null +++ b/open-sse/mcp-server/tools/notionTools.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { createNotionClient } from "../../../src/lib/notion/api.ts"; +import { getNotionToken } from "../../../src/lib/db/notion.ts"; + +function requireToken(): string { + const token = getNotionToken(); + if (!token) throw new Error("Notion integration token not configured. Set it in Settings > Context Sources."); + return token; +} + +export const notionTools = [ + { + name: "notion_search", + description: "Search pages and databases in Notion by text query. Returns matching page titles, IDs, and URL.", + scopes: ["read:notion"], + inputSchema: z.object({ + query: z.string().min(1).max(500).describe("Search query text"), + pageSize: z.number().min(1).max(100).default(20).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { query: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.searchPagesAndDatabases(args.query, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_get_page", + description: "Get the content and metadata of a Notion page by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + pageId: z.string().min(1).describe("Notion page ID (32-char hex or UUID)"), + }), + handler: async (args: { pageId: string }) => { + const client = createNotionClient(requireToken()); + return client.getPage(args.pageId); + }, + }, + { + name: "notion_list_block_children", + description: "List all block children of a Notion block or page. Returns the block tree structure.", + scopes: ["read:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Block ID to fetch children from"), + pageSize: z.number().min(1).max(100).default(50).describe("Blocks per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { blockId: string; pageSize?: number; startCursor?: string }) => { + const client = createNotionClient(requireToken()); + return client.listBlockChildren(args.blockId, args.startCursor, args.pageSize); + }, + }, + { + name: "notion_query_database", + description: "Query a Notion database with optional filters and sorts. Returns matching entries.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + filter: z.unknown().optional().describe("Optional filter object (Notion API filter format)"), + sorts: z.array(z.unknown()).optional().describe("Optional sort array (Notion API sort format)"), + pageSize: z.number().min(1).max(100).default(50).describe("Results per page (max 100)"), + startCursor: z.string().optional().describe("Pagination cursor"), + }), + handler: async (args: { + databaseId: string; + filter?: unknown; + sorts?: unknown[]; + pageSize?: number; + startCursor?: string; + }) => { + const client = createNotionClient(requireToken()); + return client.queryDatabase( + args.databaseId, + args.filter, + args.sorts, + args.startCursor, + args.pageSize + ); + }, + }, + { + name: "notion_get_database", + description: "Get metadata and schema of a Notion database by its ID.", + scopes: ["read:notion"], + inputSchema: z.object({ + databaseId: z.string().min(1).describe("Notion database ID (32-char hex or UUID)"), + }), + handler: async (args: { databaseId: string }) => { + const client = createNotionClient(requireToken()); + return client.getDatabase(args.databaseId); + }, + }, + { + name: "notion_append_blocks", + description: "Append block children to an existing Notion block or page. Maximum 100 blocks per request.", + scopes: ["write:notion"], + inputSchema: z.object({ + blockId: z.string().min(1).describe("Target block or page ID to append to"), + children: z.array(z.unknown()).describe("Array of block objects to append"), + after: z.string().optional().describe("Block ID to append after (position parameter)"), + }), + handler: async (args: { blockId: string; children: unknown[]; after?: string }) => { + const client = createNotionClient(requireToken()); + return client.appendBlocks(args.blockId, args.children, args.after); + }, + }, +]; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 1392b4d75a..e3461e4a1e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import A2ADashboardPage from "./components/A2ADashboard"; import McpDashboardPage from "./components/MCPDashboard"; import TokenSaverCard from "./components/TokenSaverCard"; +import NotionSourceCard from "./components/NotionSourceCard"; const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null; const CLOUD_ACTION_TIMEOUT_MS = 15000; @@ -121,12 +122,13 @@ type EndpointTunnelVisibility = { showNgrokTunnel: boolean; }; -type EndpointTab = "apis" | "mcp" | "a2a"; +type EndpointTab = "apis" | "mcp" | "a2a" | "context-sources"; const ENDPOINT_TABS: Array<{ value: EndpointTab; label: string; icon: string }> = [ { value: "apis", label: "APIs", icon: "api" }, { value: "mcp", label: "MCP", icon: "extension" }, { value: "a2a", label: "A2A", icon: "hub" }, + { value: "context-sources", label: "Context Sources", icon: "database" }, ]; const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = { @@ -1233,6 +1235,11 @@ export default function APIPageClient({ machineId }: Readonly : null} {activeEndpointTab === "a2a" ? : null} + {activeEndpointTab === "context-sources" ? ( +
+ +
+ ) : null} {/* Endpoint Card */} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx new file mode 100644 index 0000000000..b95fac1b2c --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Input, Badge } from "@/shared/components"; + +export default function NotionSourceCard() { + const t = useTranslations("endpoint"); + const [connected, setConnected] = useState(false); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const fetchConfig = useCallback(async () => { + try { + const res = await fetch("/api/settings/notion"); + if (res.ok) { + const data = await res.json(); + setConnected(data.connected); + } + } catch { + // Non-critical + } + }, []); + + useEffect(() => { + void fetchConfig(); + }, [fetchConfig]); + + useEffect(() => { + if (message) { + const timer = setTimeout(() => setMessage(null), 5000); + return () => clearTimeout(timer); + } + }, [message]); + + const handleSaveToken = async () => { + if (!token.trim()) { + setMessage({ type: "error", text: "Please enter a Notion integration token" }); + return; + } + setBusy(true); + setMessage(null); + try { + const res = await fetch("/api/settings/notion", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token.trim() }), + }); + const data = await res.json(); + if (res.ok) { + setConnected(true); + setMessage({ type: "success", text: data.message }); + } else { + setMessage({ type: "error", text: data.error ?? "Failed to connect" }); + setConnected(false); + } + } catch (err) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" }); + } finally { + setBusy(false); + } + }; + + const handleDisconnect = async () => { + setBusy(true); + setMessage(null); + try { + const res = await fetch("/api/settings/notion", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setConnected(false); + setToken(""); + setMessage({ type: "success", text: data.message }); + } else { + setMessage({ type: "error", text: data.error ?? "Failed to disconnect" }); + } + } catch (err) { + setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" }); + } finally { + setBusy(false); + } + }; + + return ( + +
+ + + {expanded && ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + {!connected ? ( +
+ +
+ setToken(e.target.value)} + placeholder="ntn_... or secret_..." + disabled={busy} + className="font-mono text-sm flex-1" + /> + +
+

+ Create an Internal Integration at{" "} + + https://www.notion.so/profile/integrations + +

+
+ ) : ( +
+ + Token configured. Notion tools are available via MCP. + + +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/api/settings/notion/route.ts b/src/app/api/settings/notion/route.ts new file mode 100644 index 0000000000..dc92e14189 --- /dev/null +++ b/src/app/api/settings/notion/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + getNotionConfig, + setNotionToken, + clearNotionToken, +} from "@/lib/db/notion"; +import { createNotionClient } from "@/lib/notion/api"; + +const setTokenSchema = z.object({ + token: z.string().min(1).max(500), +}).strict(); + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const config = getNotionConfig(); + return NextResponse.json({ + connected: config.connected, + hasToken: config.token !== null, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = setTokenSchema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { error: "Missing or invalid token", details: parsed.error.issues }, + { status: 400 } + ); + } + + try { + setNotionToken(parsed.data.token); + + const client = createNotionClient(parsed.data.token); + const result = await client.searchPagesAndDatabases("test", undefined, 1); + if (result && typeof result === "object" && "object" in result && (result as Record).object === "error") { + clearNotionToken(); + return NextResponse.json( + { error: "Token validation failed: invalid token", connected: false }, + { status: 400 } + ); + } + + return NextResponse.json({ + connected: true, + message: "Notion integration token saved and validated", + }); + } catch (error) { + clearNotionToken(); + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg, connected: false }, { status: 400 }); + } +} + +export async function DELETE(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + clearNotionToken(); + return NextResponse.json({ + connected: false, + message: "Notion integration disconnected", + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/lib/db/notion.ts b/src/lib/db/notion.ts new file mode 100644 index 0000000000..2a12518204 --- /dev/null +++ b/src/lib/db/notion.ts @@ -0,0 +1,48 @@ +import { getDbInstance } from "./core"; + +const NOTION_NAMESPACE = "notion"; +const NOTION_TOKEN_KEY = "integration_token"; + +type KeyValueRow = { + value?: string; +}; + +export function getNotionToken(): string | null { + try { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(NOTION_NAMESPACE, NOTION_TOKEN_KEY) as KeyValueRow | undefined; + return typeof row?.value === "string" ? JSON.parse(row.value) : null; + } catch { + return null; + } +} + +export function setNotionToken(token: string): void { + try { + const db = getDbInstance(); + db.prepare( + "INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ).run(NOTION_NAMESPACE, NOTION_TOKEN_KEY, JSON.stringify(token)); + } catch { + // Non-fatal — token still works in-memory if persistence fails. + } +} + +export function clearNotionToken(): void { + try { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + NOTION_NAMESPACE, + NOTION_TOKEN_KEY + ); + } catch { + // Non-fatal. + } +} + +export function getNotionConfig(): { token: string | null; connected: boolean } { + const token = getNotionToken(); + return { token, connected: token !== null && token.length > 0 }; +} diff --git a/src/lib/notion/api.ts b/src/lib/notion/api.ts new file mode 100644 index 0000000000..5e52b0cc2d --- /dev/null +++ b/src/lib/notion/api.ts @@ -0,0 +1,249 @@ +const NOTION_API_BASE = "https://api.notion.com/v1"; +const NOTION_VERSION = "2026-03-11"; +const MAX_RETRIES = 3; +const TIMEOUT_MS = 55000; + +export class NotionAuthError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionAuthError"; + } +} + +export class NotionNotFoundError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionNotFoundError"; + } +} + +export class NotionRateLimitError extends Error { + retryAfter: number; + constructor(msg: string, retryAfter: number) { + super(msg); + this.name = "NotionRateLimitError"; + this.retryAfter = retryAfter; + } +} + +export class NotionValidationError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionValidationError"; + } +} + +export class NotionServerError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionServerError"; + } +} + +export class NotionTimeoutError extends Error { + constructor(msg: string) { + super(msg); + this.name = "NotionTimeoutError"; + } +} + +type NotionErrorBody = { + object: "error"; + status: number; + code: string; + message: string; +}; + +function classifyNotionError(status: number, code: string, message: string): Error { + switch (status) { + case 401: + return new NotionAuthError(message); + case 403: + return new NotionAuthError(`Access denied: ${message}`); + case 404: + return new NotionNotFoundError(message); + case 409: + return new NotionValidationError(`Conflict: ${message}`); + case 429: { + const retryAfter = 1; + const match = message.match(/retry after (\d+)/i) ?? message.match(/(\d+)/); + const parsed = match ? parseInt(match[1], 10) : 1; + return new NotionRateLimitError(message, Math.max(parsed, retryAfter)); + } + case 400: + return new NotionValidationError(message); + default: + if (status >= 500) return new NotionServerError(message); + return new NotionValidationError(message); + } +} + +function sanitize(msg: string): string { + return msg.replace(/\s+at\s+\S+/g, "").replace(/\/[\w/.-]+\.[a-z]+\:\d+/g, "").slice(0, 4096); +} + +async function notionFetch( + path: string, + apiKey: string, + options: RequestInit = {} +): Promise { + const url = `${NOTION_API_BASE}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const mergedSignal = options.signal + ? combineSignals(options.signal, controller.signal) + : controller.signal; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + ...(options.headers as Record), + }, + signal: mergedSignal, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})) as Record; + const errBody = body as Partial; + const code = errBody?.code ?? "unknown"; + const msg = errBody?.message ?? `HTTP ${response.status}`; + const error = classifyNotionError(response.status, code, msg); + + if (error instanceof NotionRateLimitError) { + lastError = error; + const waitMs = error.retryAfter * 1000 + Math.pow(2, attempt) * 200; + await sleep(waitMs); + continue; + } + + if (error instanceof NotionServerError && attempt < MAX_RETRIES - 1) { + lastError = error; + await sleep(Math.pow(2, attempt) * 500); + continue; + } + + throw error; + } + + return response.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + clearTimeout(timeout); + throw new NotionTimeoutError("Notion API request timed out after 55s"); + } + if (err instanceof NotionAuthError || err instanceof NotionNotFoundError || err instanceof NotionValidationError) { + clearTimeout(timeout); + throw err; + } + if (attempt < MAX_RETRIES - 1) { + lastError = err instanceof Error ? err : new NotionServerError(String(err)); + await sleep(Math.pow(2, attempt) * 500); + continue; + } + } + } + + clearTimeout(timeout); + throw lastError ?? new NotionServerError("Exhausted all retries"); +} + +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createNotionClient(apiKey: string) { + const client = { + async searchPagesAndDatabases( + query: string, + startCursor?: string, + pageSize = 20 + ): Promise { + const body: Record = { + query, + page_size: Math.min(pageSize, 100), + filter: { value: "page", property: "object" }, + }; + if (startCursor) body.start_cursor = startCursor; + return notionFetch("/search", apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getPage(pageId: string): Promise { + return notionFetch(`/pages/${pageId}`, apiKey); + }, + + async listBlockChildren( + blockId: string, + startCursor?: string, + pageSize = 50 + ): Promise { + const params = new URLSearchParams(); + params.set("page_size", String(Math.min(pageSize, 100))); + if (startCursor) params.set("start_cursor", startCursor); + return notionFetch(`/blocks/${blockId}/children?${params}`, apiKey); + }, + + async queryDatabase( + databaseId: string, + filter?: unknown, + sorts?: unknown[], + startCursor?: string, + pageSize = 50 + ): Promise { + const body: Record = { + page_size: Math.min(pageSize, 100), + }; + if (filter) body.filter = filter; + if (sorts) body.sorts = sorts; + if (startCursor) body.start_cursor = startCursor; + return notionFetch(`/databases/${databaseId}/query`, apiKey, { + method: "POST", + body: JSON.stringify(body), + }); + }, + + async getDatabase(databaseId: string): Promise { + return notionFetch(`/databases/${databaseId}`, apiKey); + }, + + async appendBlocks( + blockId: string, + children: unknown[], + after?: string + ): Promise { + const body: Record = { + children: children.slice(0, 100), + }; + if (after) body.after = after; + return notionFetch(`/blocks/${blockId}/children`, apiKey, { + method: "PATCH", + body: JSON.stringify(body), + }); + }, + }; + + return client; +} + +export type NotionClient = ReturnType; diff --git a/tests/unit/db/notion.test.mjs b/tests/unit/db/notion.test.mjs new file mode 100644 index 0000000000..b1e204f82a --- /dev/null +++ b/tests/unit/db/notion.test.mjs @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert"; + +test("notion DB module exports expected functions", async () => { + const mod = await import("../../../src/lib/db/notion.ts"); + assert.equal(typeof mod.getNotionToken, "function"); + assert.equal(typeof mod.setNotionToken, "function"); + assert.equal(typeof mod.clearNotionToken, "function"); + assert.equal(typeof mod.getNotionConfig, "function"); +}); + +test("getNotionConfig returns expected shape", async () => { + const { getNotionConfig } = await import("../../../src/lib/db/notion.ts"); + const config = getNotionConfig(); + assert.ok(typeof config === "object"); + assert.ok("connected" in config); + assert.ok("token" in config); + assert.equal(typeof config.connected, "boolean"); +}); + +test("setNotionToken and clearNotionToken are callable without DB", async () => { + const { setNotionToken, clearNotionToken } = await import("../../../src/lib/db/notion.ts"); + assert.doesNotThrow(() => setNotionToken("test")); + assert.doesNotThrow(() => clearNotionToken()); +}); diff --git a/tests/unit/notion-api.test.ts b/tests/unit/notion-api.test.ts new file mode 100644 index 0000000000..860a5e52dd --- /dev/null +++ b/tests/unit/notion-api.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + NotionAuthError, + NotionNotFoundError, + NotionRateLimitError, + NotionValidationError, + NotionServerError, + NotionTimeoutError, +} from "../../src/lib/notion/api.ts"; + +test("NotionAuthError has correct name", () => { + const err = new NotionAuthError("bad token"); + assert.equal(err.name, "NotionAuthError"); + assert.equal(err.message, "bad token"); +}); + +test("NotionNotFoundError has correct name", () => { + const err = new NotionNotFoundError("not found"); + assert.equal(err.name, "NotionNotFoundError"); +}); + +test("NotionRateLimitError has retryAfter property", () => { + const err = new NotionRateLimitError("rate limited", 5); + assert.equal(err.retryAfter, 5); + assert.equal(err.name, "NotionRateLimitError"); +}); + +test("NotionValidationError has correct name", () => { + const err = new NotionValidationError("invalid"); + assert.equal(err.name, "NotionValidationError"); +}); + +test("NotionServerError has correct name", () => { + const err = new NotionServerError("server error"); + assert.equal(err.name, "NotionServerError"); +}); + +test("NotionTimeoutError has correct name", () => { + const err = new NotionTimeoutError("timed out"); + assert.equal(err.name, "NotionTimeoutError"); +}); + +test("createNotionClient returns object with expected methods", async () => { + const { createNotionClient } = await import("../../src/lib/notion/api.ts"); + const client = createNotionClient("test-token"); + assert.equal(typeof client.searchPagesAndDatabases, "function"); + assert.equal(typeof client.getPage, "function"); + assert.equal(typeof client.listBlockChildren, "function"); + assert.equal(typeof client.queryDatabase, "function"); + assert.equal(typeof client.getDatabase, "function"); + assert.equal(typeof client.appendBlocks, "function"); +}); diff --git a/tests/unit/notion-tools.test.ts b/tests/unit/notion-tools.test.ts new file mode 100644 index 0000000000..c7f87af7a4 --- /dev/null +++ b/tests/unit/notion-tools.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { evaluateToolScopes } from "../../open-sse/mcp-server/scopeEnforcement.ts"; + +test("notion tools — enforcement disabled allows any", () => { + const result = evaluateToolScopes("notion_search", [], false); + assert.equal(result.allowed, true); +}); + +test("notion tools — missing read:notion denied via inline scopes", () => { + const result = evaluateToolScopes("notion_search", ["read:health"], true, ["read:notion"]); + assert.equal(result.allowed, false); + assert.ok(result.missing.includes("read:notion")); +}); + +test("notion tools — correct read scope allowed via inline scopes", () => { + const result = evaluateToolScopes("notion_search", ["read:notion"], true, ["read:notion"]); + assert.equal(result.allowed, true); + assert.deepEqual(result.missing, []); +}); + +test("notion tools — wildcard read:* covers read:notion", () => { + const result = evaluateToolScopes("notion_search", ["read:*"], true, ["read:notion"]); + assert.equal(result.allowed, true); +}); + +test("notion tools — write:notion denied for read-only caller", () => { + const result = evaluateToolScopes("notion_append_blocks", ["read:notion"], true, ["write:notion"]); + assert.equal(result.allowed, false); + assert.ok(result.missing.includes("write:notion")); +}); + +test("notion tools — write:notion allowed with correct scope", () => { + const result = evaluateToolScopes("notion_append_blocks", ["write:notion"], true, ["write:notion"]); + assert.equal(result.allowed, true); +}); + +test("notion tools — tool without inline scopes returns denied with tool_definition_missing", () => { + // Without inline scopes, a tool not in MCP_TOOL_MAP is treated as missing. + const result = evaluateToolScopes("notion_search", ["read:notion"], true); + assert.equal(result.allowed, false); + assert.equal(result.reason, "tool_definition_missing"); +}); + +test("notion tools — inline scopes parameter missing scope denied", () => { + const result = evaluateToolScopes("notion_search", ["read:health"], true, ["read:notion"]); + assert.equal(result.allowed, false); +}); + +test("notion tools — each read tool validates independently", () => { + for (const name of ["notion_search", "notion_get_page", "notion_list_block_children", "notion_query_database", "notion_get_database"]) { + const result = evaluateToolScopes(name, ["read:notion"], true, ["read:notion"]); + assert.equal(result.allowed, true, `${name} should be allowed with read:notion`); + } +}); + +test("notion tools — append_blocks requires write scope", () => { + const result = evaluateToolScopes("notion_append_blocks", ["read:notion"], true, ["write:notion"]); + assert.equal(result.allowed, false); +});