Merge PR 2959 into release/v3.8.8

This commit is contained in:
diegosouzapw
2026-05-30 22:02:49 -03:00
12 changed files with 855 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, unknown>;
@@ -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;
}

View File

@@ -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);
},
},
];

View File

@@ -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<APIPageClientProps
{activeEndpointTab === "mcp" ? <McpDashboardPage /> : null}
{activeEndpointTab === "a2a" ? <A2ADashboardPage /> : null}
{activeEndpointTab === "context-sources" ? (
<div className="flex flex-col gap-4">
<NotionSourceCard />
</div>
) : null}
{/* Endpoint Card */}
<Card>

View File

@@ -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 (
<Card>
<div className="p-5">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 text-left"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-blue-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-blue-400">description</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Notion</span>
<Badge variant={connected ? "success" : "default"}>
{connected ? "Connected" : "Not connected"}
</Badge>
</div>
<p className="text-xs text-text-muted mt-0.5">
Search, read, query, and write to Notion through routed AI models
</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expanded ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{expanded && (
<div className="mt-4 pt-4 border-t border-border/50 flex flex-col gap-3">
{message && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
message.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{message.type === "success" ? "check_circle" : "error"}
</span>
<span className="flex-1">{message.text}</span>
</div>
)}
{!connected ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
Notion Internal Integration Token
</label>
<div className="flex gap-2">
<Input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ntn_... or secret_..."
disabled={busy}
className="font-mono text-sm flex-1"
/>
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
Connect
</Button>
</div>
<p className="text-[10px] text-text-muted">
Create an Internal Integration at{" "}
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
https://www.notion.so/profile/integrations
</code>
</p>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted flex-1">
Token configured. Notion tools are available via MCP.
</span>
<Button
onClick={handleDisconnect}
loading={busy}
variant="secondary"
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
>
Disconnect
</Button>
</div>
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -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<string, unknown>).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 });
}
}

48
src/lib/db/notion.ts Normal file
View File

@@ -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 };
}

249
src/lib/notion/api.ts Normal file
View File

@@ -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<unknown> {
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<string, string>),
},
signal: mergedSignal,
});
if (!response.ok) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
const errBody = body as Partial<NotionErrorBody>;
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createNotionClient(apiKey: string) {
const client = {
async searchPagesAndDatabases(
query: string,
startCursor?: string,
pageSize = 20
): Promise<unknown> {
const body: Record<string, unknown> = {
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<unknown> {
return notionFetch(`/pages/${pageId}`, apiKey);
},
async listBlockChildren(
blockId: string,
startCursor?: string,
pageSize = 50
): Promise<unknown> {
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<unknown> {
const body: Record<string, unknown> = {
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<unknown> {
return notionFetch(`/databases/${databaseId}`, apiKey);
},
async appendBlocks(
blockId: string,
children: unknown[],
after?: string
): Promise<unknown> {
const body: Record<string, unknown> = {
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<typeof createNotionClient>;

View File

@@ -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());
});

View File

@@ -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");
});

View File

@@ -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);
});