diff --git a/CHANGELOG.md b/CHANGELOG.md index 82415e4045..5dd79c7d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,14 @@ - **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) +### ✨ 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 de5b061511..90ea99c5a6 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -294,12 +294,10 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central | `read:skills` | `skills_list`, `skills_executions` | | `write:skills` | `skills_enable` | | `execute:skills` | `skills_execute` | - | `read:catalog` | `agent_skills_list`, `agent_skills_get`, `agent_skills_coverage` | Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access. -Agent Skill Catalog tools require `read:catalog`. --- ## Environment Variables diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index f9e7f07624..64d3ed9fb8 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -79,6 +79,7 @@ import { agentSkillTools } from "./tools/agentSkillTools.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 { @@ -106,7 +107,8 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(skillTools).length + Object.keys(agentSkillTools).length + gamificationTools.length + - pluginTools.length; + pluginTools.length + + notionTools.length; type JsonRecord = Record; @@ -1120,6 +1122,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); +});