Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,38 +31,16 @@ docs/new-features/

# dependencies
node_modules/
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.data/
.next-playwright/
.agents/
workflows/
.omo/

# devbox
.devbox/

# testing
coverage/
coverage**

# next.js
.next/
/out/
dist/
*.map
.DS_Store

# production
/build
/app
cloud/*
# Obsidian sync plugin — committed for community distribution
!obsidian-plugin/
obsidian-plugin/node_modules/

# misc
.DS_Store
*.pem
# Serena AI assistant config (local-only tool, not project code)
.serena/

# debug
npm-debug.log*
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### ✨ New Features

- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959)

### 🔧 Bug Fixes

- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)

---

## [3.8.9] — Unreleased
Expand Down
132 changes: 126 additions & 6 deletions open-sse/mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from "./schemas/tools.ts";
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";

import { z } from "zod";
import { closeAuditDb, logToolCall } from "./audit.ts";
import {
evaluateToolScopes,
Expand Down Expand Up @@ -76,10 +77,13 @@ import {
import { memoryTools } from "./tools/memoryTools.ts";
import { skillTools } from "./tools/skillTools.ts";
import { agentSkillTools } from "./tools/agentSkillTools.ts";
import { skillRegistry } from "../../src/lib/skills/registry.ts";
import { skillExecutor } from "../../src/lib/skills/executor.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 { obsidianTools } from "./tools/obsidianTools.ts";
import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts";
import {
Expand All @@ -93,7 +97,6 @@ import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRoute
// ============ Configuration ============

const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true";
const MCP_ALLOWED_SCOPES = new Set(
(process.env.OMNIROUTE_MCP_SCOPES || "")
Expand All @@ -108,7 +111,8 @@ const TOTAL_MCP_TOOL_COUNT =
Object.keys(agentSkillTools).length +
gamificationTools.length +
pluginTools.length +
notionTools.length;
notionTools.length +
obsidianTools.length;

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -186,11 +190,16 @@ function normalizeComboModels(
/**
* Internal fetch helper that calls OmniRoute API endpoints.
*/
function getOmniRouteApiKey(): string {
return process.env.OMNIROUTE_API_KEY || "";
}

async function omniRouteFetch(path: string, options: RequestInit = {}): Promise<unknown> {
const url = `${OMNIROUTE_BASE_URL}${path}`;
const apiKey = getOmniRouteApiKey();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...((options.headers as Record<string, string>) || {}),
};

Expand Down Expand Up @@ -666,6 +675,17 @@ export function createMcpServer(): McpServer {
return registerResource(name, uriOrTemplate as never, metadata as never, readCallback as never);
}) as typeof server.registerResource;

const RESERVED_MCP_NAMES = new Set([
...MCP_TOOLS.map((t) => t.name),
...Object.keys(memoryTools),
...Object.keys(skillTools),
...Object.keys(compressionTools),
...pluginTools.map((t) => t.name),
...gamificationTools.map((t) => t.name),
...obsidianTools.map((t) => t.name),
...notionTools.map((t) => t.name),
]);

// Register essential tools
server.registerTool(
"omniroute_get_health",
Expand Down Expand Up @@ -965,7 +985,7 @@ export function createMcpServer(): McpServer {
);

// ── Memory Tools ──────────────────────────────
Object.values(memoryTools).forEach((toolDef) => {
Object.values(memoryTools).forEach((toolDef: any) => {
server.registerTool(
toolDef.name,
{
Expand All @@ -992,7 +1012,7 @@ export function createMcpServer(): McpServer {
});

// ── Skill Tools ──────────────────────────────
Object.values(skillTools).forEach((toolDef) => {
Object.values(skillTools).forEach((toolDef: any) => {
server.registerTool(
toolDef.name,
{
Expand Down Expand Up @@ -1069,7 +1089,7 @@ export function createMcpServer(): McpServer {
});

// ── Compression Tools ─────────────────────────
Object.values(compressionTools).forEach((toolDef) => {
Object.values(compressionTools).forEach((toolDef: any) => {
server.registerTool(
toolDef.name,
{
Expand Down Expand Up @@ -1149,6 +1169,106 @@ export function createMcpServer(): McpServer {
);
});

// ── Obsidian Context Source Tools ─────────────
obsidianTools.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
)
);
});

// ── Notion Context Source Tools ───────────────
notionTools.forEach((toolDef) => {
server.registerTool(
Comment on lines +1199 to +1201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove duplicate Notion tool registration

When createMcpServer() runs, this second notionTools.forEach registers every Notion tool name after the earlier Notion block at lines 1145-1170 already registered the same names. The MCP SDK rejects duplicate tool names during registration, so starting the MCP server now fails before any tools (including the new Obsidian tools) are usable.

Useful? React with 👍 / 👎.

Comment on lines +1199 to +1201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove duplicate Notion tool registration

When createMcpServer() runs, this second notionTools.forEach registers every Notion tool name after the earlier Notion block at lines 1145-1170 already registered the same names. The MCP SDK rejects duplicate tool names during registration, so starting the MCP server now fails before any tools (including the new Obsidian tools) are usable.

Useful? React with 👍 / 👎.

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

// ── Dynamic Skill Tools (from skills table) ──
const skillToMcpToolName = (skill: { name: string }) => `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`;
try {
const enabledSkills = skillRegistry.list().filter((s) => s.enabled);
for (const skill of enabledSkills) {
const toolName = skillToMcpToolName(skill);
if (RESERVED_MCP_NAMES.has(toolName)) continue;

server.registerTool(
toolName,
{
description: skill.description,
inputSchema: z.object({}).passthrough(),
},
withScopeEnforcement(
toolName,
async (args, extra) => {
const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES));
const apiKeyId = scopeContext.callerId || "mcp";
try {
const execution = await skillExecutor.execute(
skill.name,
(args ?? {}) as Record<string, unknown>,
{ apiKeyId }
);
return {
content: [
{ type: "text" as const, text: JSON.stringify(execution.output, null, 2) },
],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text" as const, text: `Error: ${msg}` }],
isError: true,
};
}
},
["execute:skills"]
)
);
}
} catch {
// Skills not loaded yet — skip dynamic registration until next reconnect
}

return server;
}

Expand Down
Loading