diff --git a/open-sse/mcp-server/scopeEnforcement.ts b/open-sse/mcp-server/scopeEnforcement.ts index 4c622d1e2f..9d0477c16c 100644 --- a/open-sse/mcp-server/scopeEnforcement.ts +++ b/open-sse/mcp-server/scopeEnforcement.ts @@ -99,26 +99,28 @@ export function resolveCallerScopeContext( export function evaluateToolScopes( toolName: string, callerScopes: readonly string[], - enforceScopes: boolean + enforceScopes: boolean, + inlineScopes?: readonly string[] ): ScopeCheckResult { - const toolDef = MCP_TOOL_MAP[toolName]; - if (!toolDef) { + const provided = normalizeScopeList(callerScopes); + + if (!enforceScopes) { + return { allowed: true, required: [], provided, missing: [] }; + } + + const toolScopes = inlineScopes ?? MCP_TOOL_MAP[toolName]?.scopes; + const required = Array.isArray(toolScopes) ? Array.from(toolScopes) : []; + + if (required.length === 0) { return { allowed: false, required: [], - provided: Array.from(callerScopes), + provided, missing: [], reason: "tool_definition_missing", }; } - const required = Array.isArray(toolDef.scopes) ? Array.from(toolDef.scopes) : []; - const provided = normalizeScopeList(callerScopes); - - if (!enforceScopes || required.length === 0) { - return { allowed: true, required, provided, missing: [] }; - } - const missing = required.filter( (requiredScope) => !provided.some((grantedScope) => scopeMatches(grantedScope, requiredScope)) ); diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 6df518f58a..8fc4f1d199 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -205,11 +205,12 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< function withScopeEnforcement( toolName: string, - handler: (args: unknown, extra?: McpToolExtraLike) => Promise + handler: (args: unknown, extra?: McpToolExtraLike) => Promise, + toolScopes?: readonly string[] ) { return async (args: unknown, extra?: McpToolExtraLike): Promise => { const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); - const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES); + const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes); if (!scopeCheck.allowed) { const missingScopes = scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; @@ -962,7 +963,7 @@ export function createMcpServer(): McpServer { ); // ── Memory Tools ────────────────────────────── - Object.values(memoryTools).forEach((toolDef) => { + Object.values(memoryTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -970,22 +971,26 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - 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 }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + 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 + ) ); }); // ── Skill Tools ────────────────────────────── - Object.values(skillTools).forEach((toolDef) => { + Object.values(skillTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -993,17 +998,21 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - 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 }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + 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 + ) ); }); @@ -1016,22 +1025,26 @@ export function createMcpServer(): McpServer { // @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 }; - } - }) + 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 + ) ); }); // ── Compression Tools ───────────────────────── - Object.values(compressionTools).forEach((toolDef) => { + Object.values(compressionTools).forEach((toolDef: any) => { server.registerTool( toolDef.name, { @@ -1039,17 +1052,21 @@ export function createMcpServer(): McpServer { // @ts-ignore: dynamic zod access inputSchema: toolDef.inputSchema, }, - withScopeEnforcement(toolDef.name, async (args) => { - try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); - // @ts-expect-error - handler type lost through dynamic Object.values() access - 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 }; - } - }) + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + 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 + ) ); }); @@ -1062,17 +1079,21 @@ export function createMcpServer(): McpServer { // @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 }; - } - }) + 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 + ) ); }); diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 382a977634..91075f7de4 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -299,6 +299,7 @@ export const compressionTools = { name: "omniroute_compression_status", description: "Returns current compression configuration, strategy, analytics summary (requests compressed, tokens saved, avg ratio), and provider-aware cache statistics.", + scopes: ["read:compression"], inputSchema: compressionStatusInput, handler: (args: z.infer) => handleCompressionStatus(args), }, @@ -306,24 +307,28 @@ export const compressionTools = { name: "omniroute_compression_configure", description: "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", + scopes: ["write:compression"], inputSchema: compressionConfigureInput, handler: (args: z.infer) => handleCompressionConfigure(args), }, omniroute_set_compression_engine: { name: "omniroute_set_compression_engine", description: "Set the active compression engine and Caveman/RTK runtime options.", + scopes: ["write:compression"], inputSchema: setCompressionEngineInput, handler: (args: z.infer) => handleSetCompressionEngine(args), }, omniroute_list_compression_combos: { name: "omniroute_list_compression_combos", description: "List compression combos and their engine pipelines.", + scopes: ["read:compression"], inputSchema: listCompressionCombosInput, handler: (_args: z.infer) => handleListCompressionCombos(), }, omniroute_compression_combo_stats: { name: "omniroute_compression_combo_stats", description: "Get compression analytics grouped by engine and compression combo.", + scopes: ["read:compression"], inputSchema: compressionComboStatsInput, handler: (args: z.infer) => handleCompressionComboStats(args), diff --git a/open-sse/mcp-server/tools/gamificationTools.ts b/open-sse/mcp-server/tools/gamificationTools.ts index de42fb2ff8..99321aa5a8 100644 --- a/open-sse/mcp-server/tools/gamificationTools.ts +++ b/open-sse/mcp-server/tools/gamificationTools.ts @@ -10,6 +10,7 @@ export const gamificationTools = [ { name: "gamification_leaderboard", description: "Get leaderboard rankings for a scope (global, weekly, monthly, tokens_shared).", + scopes: ["read:gamification"], inputSchema: z.object({ scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), limit: z.number().min(1).max(100).default(50), @@ -23,6 +24,7 @@ export const gamificationTools = [ { name: "gamification_rank", description: "Get rank for an API key in a leaderboard scope.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), scope: z.enum(["global", "weekly", "monthly", "tokens_shared"]).default("global"), @@ -36,6 +38,7 @@ export const gamificationTools = [ { name: "gamification_profile", description: "Get XP, level, and badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string(), }), @@ -64,6 +67,7 @@ export const gamificationTools = [ { name: "gamification_badges", description: "List all badge definitions or earned badges for an API key.", + scopes: ["read:gamification"], inputSchema: z.object({ apiKeyId: z.string().optional(), category: z.string().optional(), @@ -83,6 +87,7 @@ export const gamificationTools = [ { name: "gamification_transfer", description: "Transfer tokens between API keys.", + scopes: ["write:gamification"], inputSchema: z.object({ fromApiKeyId: z.string(), toApiKeyId: z.string(), @@ -108,6 +113,7 @@ export const gamificationTools = [ { name: "gamification_invite", description: "Create an invite token for server connection.", + scopes: ["write:gamification"], inputSchema: z.object({ apiKeyId: z.string(), serverUrl: z.string().optional(), @@ -122,6 +128,7 @@ export const gamificationTools = [ { name: "gamification_servers", description: "List connected community servers.", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { listServers } = await import("../../../src/lib/gamification/servers"); @@ -131,6 +138,7 @@ export const gamificationTools = [ { name: "gamification_anomalies", description: "Get flagged anomalous XP activity (admin only).", + scopes: ["read:gamification"], inputSchema: z.object({}), handler: async () => { const { getAnomalies } = await import("../../../src/lib/gamification/antiCheat"); diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts index 9ac7dd9ce2..da2ce1dfc1 100644 --- a/open-sse/mcp-server/tools/memoryTools.ts +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -30,6 +30,7 @@ export const memoryTools = { omniroute_memory_search: { name: "omniroute_memory_search", description: "Search memories by query, type, or API key with token budget enforcement", + scopes: ["read:memory"], inputSchema: MemorySearchSchema, handler: async (args: z.infer) => { const config = { @@ -63,6 +64,7 @@ export const memoryTools = { omniroute_memory_add: { name: "omniroute_memory_add", description: "Add a new memory entry", + scopes: ["write:memory"], inputSchema: MemoryAddSchema, handler: async (args: z.infer) => { const memory = await createMemory({ @@ -88,6 +90,7 @@ export const memoryTools = { omniroute_memory_clear: { name: "omniroute_memory_clear", description: "Clear memories for an API key, optionally filtered by type or age", + scopes: ["write:memory"], inputSchema: MemoryClearSchema, handler: async (args: z.infer) => { const result = await listMemories({ diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts index a6d4a3f9b1..ed8dd9be97 100644 --- a/open-sse/mcp-server/tools/pluginTools.ts +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -12,6 +12,7 @@ export const pluginTools = [ { name: "plugin_list", description: "List all installed plugins with their status, hooks, and metadata.", + scopes: ["read:plugins"], inputSchema: z.object({ status: z .enum(["installed", "active", "inactive", "error"]) @@ -39,6 +40,7 @@ export const pluginTools = [ { name: "plugin_install", description: "Install a plugin from a local directory path.", + scopes: ["write:plugins"], inputSchema: z.object({ path: z.string().describe("Absolute path to the plugin directory containing plugin.json"), }), @@ -58,6 +60,7 @@ export const pluginTools = [ { name: "plugin_activate", description: "Activate an installed plugin (loads hooks into the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -70,6 +73,7 @@ export const pluginTools = [ { name: "plugin_deactivate", description: "Deactivate an active plugin (unloads hooks from the request pipeline).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -82,6 +86,7 @@ export const pluginTools = [ { name: "plugin_uninstall", description: "Uninstall a plugin (deactivates, removes files, removes from DB).", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name (kebab-case)"), }), @@ -94,6 +99,7 @@ export const pluginTools = [ { name: "plugin_configure", description: "Get or update a plugin's configuration.", + scopes: ["write:plugins"], inputSchema: z.object({ name: z.string().describe("Plugin name"), config: z @@ -122,6 +128,7 @@ export const pluginTools = [ { name: "plugin_executions", description: "View plugin execution history (from skill_executions table).", + scopes: ["read:plugins"], inputSchema: z.object({ name: z.string().optional().describe("Filter by plugin name"), limit: z.number().min(1).max(100).default(20).describe("Max results to return"), @@ -137,6 +144,7 @@ export const pluginTools = [ { name: "plugin_scan", description: "Scan the plugin directory for new plugins and sync with DB.", + scopes: ["write:plugins"], inputSchema: z.object({}), handler: async () => { const result = await pluginManager.scan(); diff --git a/open-sse/mcp-server/tools/skillTools.ts b/open-sse/mcp-server/tools/skillTools.ts index 2ea5d8ddb9..bb3f0acb6e 100644 --- a/open-sse/mcp-server/tools/skillTools.ts +++ b/open-sse/mcp-server/tools/skillTools.ts @@ -25,6 +25,7 @@ export const skillTools = { omniroute_skills_list: { name: "omniroute_skills_list", description: "List all registered skills with optional filtering by API key or name", + scopes: ["read:skills"], inputSchema: SkillListSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -55,6 +56,7 @@ export const skillTools = { omniroute_skills_enable: { name: "omniroute_skills_enable", description: "Enable or disable a specific skill by ID", + scopes: ["write:skills"], inputSchema: SkillEnableSchema, handler: async (args: z.infer) => { await skillRegistry.loadFromDatabase(args.apiKeyId); @@ -70,6 +72,7 @@ export const skillTools = { omniroute_skills_execute: { name: "omniroute_skills_execute", description: "Execute a skill with provided input and return the result", + scopes: ["execute:skills"], inputSchema: SkillExecuteSchema, handler: async (args: z.infer) => { const execution = await skillExecutor.execute(args.skillName, args.input, { @@ -92,6 +95,7 @@ export const skillTools = { omniroute_skills_executions: { name: "omniroute_skills_executions", description: "List recent skill execution history", + scopes: ["read:skills"], inputSchema: z.object({ apiKeyId: z.string().optional(), limit: z.number().int().positive().max(100).optional(),