context-firewall
Advanced tools
| /** | ||
| * Per-server tool allow/deny policy (GitHub issue #1). `allowTools`/`denyTools` on a downstream | ||
| * config entry restrict which of that server's tools `invoke_tool` will dispatch to. Deny wins | ||
| * over allow; both support exact names and `*` globs. | ||
| */ | ||
| /** | ||
| * Deliberately NOT regex-based. Compiling the pattern to `^${literal}.*${literal}.*...$` has two | ||
| * real failure modes: | ||
| * (a) adjacent `*`s (e.g. `a**b`) compile to consecutive `.*.*`, and on a long tool name that | ||
| * triggers catastrophic backtracking in the regex engine - the gateway can hang on a single | ||
| * policy check (measured: 4+ stars against a ~1000-char name took multiple seconds). | ||
| * (b) `.` in a regex doesn't match `\n` by default, so `delete_*` fails to catch a (adversarial | ||
| * or just weird) tool name containing a newline, e.g. "delete_evil\ninject" - a silent | ||
| * fail-open in a security-relevant check. | ||
| * Instead this is the standard two-pointer greedy wildcard matcher (star-only, no `?`): O(n*m) | ||
| * worst case, no backtracking blowup, and compares characters directly so `*` matches literally | ||
| * any character including `\n`. | ||
| */ | ||
| export function matchesToolPattern(toolName, pattern) { | ||
| let s = 0; | ||
| let p = 0; | ||
| let starIdx = -1; | ||
| let matchFrom = 0; | ||
| while (s < toolName.length) { | ||
| if (p < pattern.length && pattern[p] === toolName[s]) { | ||
| s++; | ||
| p++; | ||
| } | ||
| else if (p < pattern.length && pattern[p] === '*') { | ||
| starIdx = p; | ||
| matchFrom = s; | ||
| p++; | ||
| } | ||
| else if (starIdx !== -1) { | ||
| // Backtrack to the most recent '*' and let it swallow one more character. | ||
| p = starIdx + 1; | ||
| matchFrom++; | ||
| s = matchFrom; | ||
| } | ||
| else { | ||
| return false; | ||
| } | ||
| } | ||
| while (p < pattern.length && pattern[p] === '*') { | ||
| p++; | ||
| } | ||
| return p === pattern.length; | ||
| } | ||
| export function checkToolPolicy(downstream, tool) { | ||
| if (!downstream) { | ||
| return { allowed: true }; | ||
| } | ||
| for (const pattern of downstream.denyTools ?? []) { | ||
| if (matchesToolPattern(tool, pattern)) { | ||
| return { allowed: false, rule: `denyTools: "${pattern}"` }; | ||
| } | ||
| } | ||
| if (downstream.allowTools && downstream.allowTools.length > 0) { | ||
| const matched = downstream.allowTools.some((pattern) => matchesToolPattern(tool, pattern)); | ||
| if (!matched) { | ||
| return { allowed: false, rule: 'not matched by allowTools' }; | ||
| } | ||
| } | ||
| return { allowed: true }; | ||
| } |
+4
-0
@@ -7,2 +7,4 @@ import { readFileSync } from 'node:fs'; | ||
| env: z.record(z.string(), z.string()).optional(), | ||
| allowTools: z.array(z.string()).optional(), | ||
| denyTools: z.array(z.string()).optional(), | ||
| }); | ||
@@ -12,2 +14,4 @@ const httpDownstreamSchema = z.object({ | ||
| transport: z.literal('streamable-http').optional(), | ||
| allowTools: z.array(z.string()).optional(), | ||
| denyTools: z.array(z.string()).optional(), | ||
| }); | ||
@@ -14,0 +18,0 @@ const downstreamSchema = z.union([stdioDownstreamSchema, httpDownstreamSchema]); |
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { resolvePolicy } from '../config.js'; | ||
| import { runPipeline } from '../pipeline/index.js'; | ||
| import { checkToolPolicy } from '../tool-policy.js'; | ||
| import { LIST_TOOL_CATEGORIES, SEARCH_TOOLS, INVOKE_TOOL, READ_MORE, buildListToolCategoriesDescription, buildSearchToolsDescription, buildInvokeToolDescription, } from './meta-tools.js'; | ||
@@ -14,3 +15,3 @@ function textResult(text, isError = false) { | ||
| const { manager, logger, config, store, onCallStats } = deps; | ||
| const server = new McpServer({ name: 'context-firewall', version: '0.1.0' }); | ||
| const server = new McpServer({ name: 'context-firewall', version: '0.2.0' }); | ||
| const registry = manager.getRegistry(); | ||
@@ -34,3 +35,10 @@ const listToolCategoriesTool = server.registerTool(LIST_TOOL_CATEGORIES.name, { description: LIST_TOOL_CATEGORIES.description, inputSchema: LIST_TOOL_CATEGORIES.inputSchema }, () => { | ||
| logger.debug(`search_tools called: query="${query}" limit=${limit ?? ''}`); | ||
| const results = registry.searchTools(query, limit ?? 5); | ||
| const effectiveLimit = limit ?? 5; | ||
| // Fixed-size over-fetch window so policy-blocked candidates can be filtered out below and | ||
| // still leave up to `effectiveLimit` results. Not adaptive: if deny-listed tools make up | ||
| // a large enough share of the true top matches, fewer than `effectiveLimit` may come back. | ||
| const candidates = registry.searchTools(query, Math.max(effectiveLimit * 4, 20)); | ||
| const results = candidates | ||
| .filter((r) => checkToolPolicy(config.downstreams[r.server], r.name).allowed) | ||
| .slice(0, effectiveLimit); | ||
| if (results.length === 0) { | ||
@@ -48,2 +56,6 @@ return textResult('no tools matched; try broader keywords or list_tool_categories'); | ||
| logger.debug(`invoke_tool called: server="${serverName}" tool="${tool}"`); | ||
| const policyCheck = checkToolPolicy(config.downstreams[serverName], tool); | ||
| if (!policyCheck.allowed) { | ||
| return textResult(`Tool "${tool}" on server "${serverName}" is blocked by config policy (${policyCheck.rule})`, true); | ||
| } | ||
| let result; | ||
@@ -50,0 +62,0 @@ try { |
+1
-1
| { | ||
| "name": "context-firewall", | ||
| "mcpName": "io.github.Alepha188838884/context-firewall", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "MCP proxy that collapses 50+ downstream tools into 4 meta-tools and compresses tool outputs (HTML, base64, huge JSON) so AI agents spend far fewer context tokens - works with any MCP client, any model.", | ||
@@ -6,0 +6,0 @@ "type": "module", |
+12
-0
@@ -67,2 +67,14 @@ # Context Firewall | ||
| **Per-tool allow/deny policy.** Add `allowTools`/`denyTools` (array of exact names or `*` globs) to any downstream entry to restrict which of its tools can be invoked: | ||
| ```json | ||
| "github": { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-github"], | ||
| "denyTools": ["delete_*"] | ||
| } | ||
| ``` | ||
| Deny always wins over allow. When `allowTools` is set, only matching tools are permitted; everything else on that server is blocked. An empty `allowTools: []` is treated the same as omitting it (allow everything), not "deny everything". Blocked tools are hidden from `search_tools` results, and `invoke_tool` rejects them before dispatching to the downstream server. Tool counts in `list_tool_categories` and in the meta-tool descriptions are unfiltered totals — the policy is only enforced at `search_tools`/`invoke_tool` time. | ||
| ## Client setup | ||
@@ -69,0 +81,0 @@ |
+12
-0
@@ -67,2 +67,14 @@ # Context Firewall | ||
| **按工具粒度的允许/拒绝策略。** 在任意下游配置项上加 `allowTools`/`denyTools`(元素为精确名称或带 `*` 的 glob),即可限制该 server 上哪些工具可被调用: | ||
| ```json | ||
| "github": { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-github"], | ||
| "denyTools": ["delete_*"] | ||
| } | ||
| ``` | ||
| 拒绝始终优先于允许。设置了 `allowTools` 后,只有匹配到的工具才被放行,该 server 上其余工具一律拒绝。空数组 `allowTools: []` 等同于不设置(即全部允许),而不是"全部拒绝"。被拒绝的工具不会出现在 `search_tools` 的结果里,`invoke_tool` 也会在派发到下游 server 之前就拒绝调用。`list_tool_categories` 里的工具计数以及元工具描述里的数字都是未经策略过滤的全量计数——策略只在 `search_tools`/`invoke_tool` 时才会生效。 | ||
| ## 客户端配置 | ||
@@ -69,0 +81,0 @@ |
89397
6.28%25
4.17%1506
5.68%218
5.83%