mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
Merge PR 2959 into release/v3.8.8
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
106
open-sse/mcp-server/tools/notionTools.ts
Normal file
106
open-sse/mcp-server/tools/notionTools.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
89
src/app/api/settings/notion/route.ts
Normal file
89
src/app/api/settings/notion/route.ts
Normal 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
48
src/lib/db/notion.ts
Normal 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
249
src/lib/notion/api.ts
Normal 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>;
|
||||
25
tests/unit/db/notion.test.mjs
Normal file
25
tests/unit/db/notion.test.mjs
Normal 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());
|
||||
});
|
||||
54
tests/unit/notion-api.test.ts
Normal file
54
tests/unit/notion-api.test.ts
Normal 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");
|
||||
});
|
||||
61
tests/unit/notion-tools.test.ts
Normal file
61
tests/unit/notion-tools.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user