fix(mcp): reorder enforceScopes guard before MCP_TOOL_MAP lookup, add scopes to all dynamic tool definitions (#2958)

Integrated into release/v3.8.8
This commit is contained in:
Brandon Bennett
2026-05-30 20:18:00 -04:00
committed by GitHub
parent b778ad2614
commit 38221f2040
7 changed files with 122 additions and 71 deletions

View File

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

View File

@@ -205,11 +205,12 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise<
function withScopeEnforcement(
toolName: string,
handler: (args: unknown, extra?: McpToolExtraLike) => Promise<TextToolResult>
handler: (args: unknown, extra?: McpToolExtraLike) => Promise<TextToolResult>,
toolScopes?: readonly string[]
) {
return async (args: unknown, extra?: McpToolExtraLike): Promise<TextToolResult> => {
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
)
);
});

View File

@@ -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<typeof compressionStatusInput>) => 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<typeof compressionConfigureInput>) => 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<typeof setCompressionEngineInput>) => 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<typeof listCompressionCombosInput>) => 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<typeof compressionComboStatsInput>) =>
handleCompressionComboStats(args),

View File

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

View File

@@ -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<typeof MemorySearchSchema>) => {
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<typeof MemoryAddSchema>) => {
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<typeof MemoryClearSchema>) => {
const result = await listMemories({

View File

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

View File

@@ -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<typeof SkillListSchema>) => {
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<typeof SkillEnableSchema>) => {
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<typeof SkillExecuteSchema>) => {
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(),