From 48d5e468e62fbcd53c5014370bfa702c4cdcf399 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 12 May 2026 17:39:14 +0800 Subject: [PATCH] feat(mcp): allowlist EXTRA merges with default; add Trilium keys to default Co-authored-by: Cursor --- _local/system.env.example | 4 +- docs/ENVIRONMENT_VARIABLES.md | 9 ++++- docs/ENVIRONMENT_VARIABLES_CHANGELOG.md | 3 ++ docs/MCP_LOCAL_SERVER.md | 4 +- runtime/operations/mcp_env.py | 35 ++++++++++++++--- tests/test_mcp_adapter.py | 52 +++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/_local/system.env.example b/_local/system.env.example index c13e9c2c..7838af45 100644 --- a/_local/system.env.example +++ b/_local/system.env.example @@ -184,9 +184,11 @@ AIA_LOCAL_ADAPTER_STARTUP_SELF_CHECK=1 # AIA_MCP_SPECIALISTS 允许使用 MCP 的专家角色列表,逗号分隔。 AIA_MCP_SPECIALISTS=generalist,manager -# AIA_MCP_ENV_ALLOWLIST 允许注入 MCP 子进程的环境变量名列表。 +# AIA_MCP_ENV_ALLOWLIST 允许注入 MCP 子进程的环境变量名列表(非空则整表替换内置默认,慎用)。 # 【前端】管理后台可维护同名设置。 AIA_MCP_ENV_ALLOWLIST= +# AIA_MCP_ENV_ALLOWLIST_EXTRA 在默认名单(或未设置 AIA_MCP_ENV_ALLOWLIST 时的内置默认)之后追加的变量名,逗号分隔;不必重复抄写默认里的键。 +AIA_MCP_ENV_ALLOWLIST_EXTRA= # AIA_MCP_SQLITE_COMMAND 表格附件走 MCP 时的 sqlite 命令路径覆盖。 AIA_TABULAR_USE_MCP= diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index ca9b72db..0b5be5c5 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -232,8 +232,13 @@ - 生效:`oclaw/tools/mcp/adapter.py` - `AIA_MCP_ENV_ALLOWLIST` - - 默认:内置 allowlist(Brave/Google/GitHub/Context7/DashScope) - - 作用:MCP 子进程可透传环境变量白名单 + - 默认:未设置时使用内置 allowlist(Brave/Google/GitHub/Context7/DashScope、Trilium MCP 等,见 `mcp_env._DEFAULT_ALLOWLIST`) + - 作用:若**非空**,则整表替换内置默认(仅列出的名可传入 MCP 子进程);用于刻意缩小暴露面 + - 生效:`oclaw/runtime/operations/mcp_env.py` + +- `AIA_MCP_ENV_ALLOWLIST_EXTRA`(兼容 `OPS_MCP_ENV_ALLOWLIST_EXTRA`) + - 默认:空 + - 作用:在「当前主列表」(内置默认,或 `AIA_MCP_ENV_ALLOWLIST` 替换后的列表)之后**追加**变量名,合并去重;新增第三方 MCP 时优先用此项,无需手抄整份默认名单 - 生效:`oclaw/runtime/operations/mcp_env.py` - `AIA_MCP_FILESYSTEM_EXTRA_ROOTS` diff --git a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md index a2f211c5..6b408add 100644 --- a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md +++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md @@ -64,8 +64,11 @@ - `AIA_IMAGE_EXPERT_REQUEST_EXTRA`:图片专家顶层 JSON;旧名 **`AIA_LEGACY_IMAGE_REQUEST_EXTRA`** 仍作别名可读。 - `DASHSCOPE_IMAGE_*`(零散变量):由 `image_legacy_client` 映射为请求体顶层字段。 - **`AIA_OCR_*`**(四项):仅存 **`query_image_attachment` / OCR 降级** 链路;已与图片专家链路拆分。 +- `AIA_MCP_ENV_ALLOWLIST_EXTRA`(兼容 `OPS_MCP_ENV_ALLOWLIST_EXTRA`):在默认主表或 `AIA_MCP_ENV_ALLOWLIST` 替换表之后追加 MCP 子进程可透传的变量名,合并去重,避免为单个 MCP 手抄整份默认名单。 +- 内置 MCP 环境透传默认名单增加 `TRILIUM_API_URL`、`TRILIUM_API_TOKEN`、`PERMISSIONS`、`VERBOSE`([triliumnext-mcp](https://github.com/tan-yong-sheng/triliumnext-mcp))。 ### Changed +- `mcp_env.mcp_env_allowlist_keys()`:除「`AIA_MCP_ENV_ALLOWLIST` 非空则整表替换内置默认」外,`EXTRA` 始终追加到当前主表之后。 - `send_ocr_image_messages` 未配 `AIA_OCR_MODEL`(且未传 `model`)失败;图片专家 **`send_legacy_image_messages`** 首选 **用户所选会话/专家绑定的模型的 `model`/`base_url`/`api_key`**,缺省时再回落 **`AIA_IMAGE_EXPERT_*`**(不读取 `AIA_OCR_*`);服务端若模型不支持看图则直接报错,不做备用 payload。 ### Removed(OCR 通道) diff --git a/docs/MCP_LOCAL_SERVER.md b/docs/MCP_LOCAL_SERVER.md index 2c89c044..dddaae65 100644 --- a/docs/MCP_LOCAL_SERVER.md +++ b/docs/MCP_LOCAL_SERVER.md @@ -505,13 +505,13 @@ Keep responses deterministic and JSON-serializable. - **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。 - **安装**:`python scripts/install_mcp_context7.py`,或管理台 `POST /admin/api/mcp/install` 使用 [`examples/mcp_install_context7.json`](../examples/mcp_install_context7.json) 中的 `payload`。 -- **密钥**:在 **`oclaw/_local/mcp_local.env`**(推荐)或 `data/mcp_local.env`(兼容)设置 `CONTEXT7_API_KEY`(见 [context7.com/dashboard](https://context7.com/dashboard))。两处都存在时**同键以 `oclaw/_local/mcp_local.env` 为准**(覆盖 `data` 中的同键)。未自定义 `OPS_MCP_ENV_ALLOWLIST` 时,网关默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/runtime/operations/mcp_env.py`);若你自定义了 allowlist,请手动追加该键。 +- **密钥**:在 **`oclaw/_local/mcp_local.env`**(推荐)或 `data/mcp_local.env`(兼容)设置 `CONTEXT7_API_KEY`(见 [context7.com/dashboard](https://context7.com/dashboard))。两处都存在时**同键以 `oclaw/_local/mcp_local.env` 为准**(覆盖 `data` 中的同键)。内置默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/runtime/operations/mcp_env.py`);若你用 **`AIA_MCP_ENV_ALLOWLIST` 整表替换**默认,须在该列表里显式保留 `CONTEXT7_API_KEY`,或改用 **`AIA_MCP_ENV_ALLOWLIST_EXTRA`** 只追加新变量名而不动默认集合。 - **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。 ### Bailian WebSearch(DashScope) - **密钥**:在 `oclaw/_local/mcp_local.env`(推荐)设置 `DASHSCOPE_API_KEY=...`。 -- **关键注意**:如果你自定义了 `AIA_MCP_ENV_ALLOWLIST` / `OPS_MCP_ENV_ALLOWLIST`,必须显式包含 `DASHSCOPE_API_KEY`,否则 MCP 子进程拿不到该密钥,常见表现是: +- **关键注意**:若使用 **`AIA_MCP_ENV_ALLOWLIST` 整表替换**内置默认,须在该列表里显式包含 `DASHSCOPE_API_KEY`,否则 MCP 子进程拿不到该密钥。更省事的做法是:**不要设置** `AIA_MCP_ENV_ALLOWLIST`,只在 **`AIA_MCP_ENV_ALLOWLIST_EXTRA`** 里追加其它 MCP 需要的变量名(与默认名单自动合并)。常见表现是: - `error_code: mcp_runtime_empty_response` - `error: empty_response` - **排查顺序**: diff --git a/runtime/operations/mcp_env.py b/runtime/operations/mcp_env.py index 932e868f..51aeb027 100644 --- a/runtime/operations/mcp_env.py +++ b/runtime/operations/mcp_env.py @@ -9,15 +9,40 @@ from oclaw.platform.config.paths import PROJECT_ROOT, db_path _DEFAULT_ALLOWLIST = ( "BRAVE_API_KEY,GOOGLE_OAUTH_CREDENTIALS,GOOGLE_CALENDAR_MCP_TOKEN_PATH," - "GITHUB_PERSONAL_ACCESS_TOKEN,CONTEXT7_API_KEY,DASHSCOPE_API_KEY" + "GITHUB_PERSONAL_ACCESS_TOKEN,CONTEXT7_API_KEY,DASHSCOPE_API_KEY," + "TRILIUM_API_URL,TRILIUM_API_TOKEN,PERMISSIONS,VERBOSE" ) +def _split_csv_keys(raw: str) -> list[str]: + return [x.strip() for x in str(raw or "").split(",") if x.strip()] + + +def _dedupe_preserve_order(keys: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for k in keys: + if k not in seen: + seen.add(k) + out.append(k) + return out + + def mcp_env_allowlist_keys() -> list[str]: - raw = str(os.getenv("AIA_MCP_ENV_ALLOWLIST") or "").strip() - if not raw: - return [x.strip() for x in _DEFAULT_ALLOWLIST.split(",") if x.strip()] - return [x.strip() for x in raw.split(",") if x.strip()] + """MCP 子进程可继承的环境变量名:主列表 + EXTRA 合并去重。 + + - 未设置 ``AIA_MCP_ENV_ALLOWLIST``:主列表为内置默认(含常用 MCP 密钥名)。 + - 已设置:主列表仅为该项(完全替换默认),用于刻意缩小暴露面。 + - ``AIA_MCP_ENV_ALLOWLIST_EXTRA``(或兼容 ``OPS_MCP_ENV_ALLOWLIST_EXTRA``): + 始终在主列表之后追加,避免为新增 MCP 手抄整份默认名单。 + """ + primary_raw = str(os.getenv("AIA_MCP_ENV_ALLOWLIST") or "").strip() + primary = _split_csv_keys(primary_raw) if primary_raw else _split_csv_keys(_DEFAULT_ALLOWLIST) + extra_raw = str(os.getenv("AIA_MCP_ENV_ALLOWLIST_EXTRA") or "").strip() + if not extra_raw: + extra_raw = str(os.getenv("OPS_MCP_ENV_ALLOWLIST_EXTRA") or "").strip() + extra = _split_csv_keys(extra_raw) + return _dedupe_preserve_order([*primary, *extra]) def _parse_env_file(path: Path) -> dict[str, str]: diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index e45288b0..26694b18 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -159,6 +159,9 @@ class McpAdapterTests(unittest.TestCase): from oclaw.runtime.operations import mcp_env old = os.environ.pop("OPS_MCP_ENV_ALLOWLIST", None) + old_aia = os.environ.pop("AIA_MCP_ENV_ALLOWLIST", None) + old_extra = os.environ.pop("AIA_MCP_ENV_ALLOWLIST_EXTRA", None) + old_ops_extra = os.environ.pop("OPS_MCP_ENV_ALLOWLIST_EXTRA", None) try: keys = mcp_env.mcp_env_allowlist_keys() self.assertIn("GOOGLE_OAUTH_CREDENTIALS", keys) @@ -167,9 +170,58 @@ class McpAdapterTests(unittest.TestCase): self.assertIn("GITHUB_PERSONAL_ACCESS_TOKEN", keys) self.assertIn("CONTEXT7_API_KEY", keys) self.assertIn("DASHSCOPE_API_KEY", keys) + self.assertIn("TRILIUM_API_TOKEN", keys) finally: if old is not None: os.environ["OPS_MCP_ENV_ALLOWLIST"] = old + if old_aia is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST"] = old_aia + if old_extra is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST_EXTRA"] = old_extra + if old_ops_extra is not None: + os.environ["OPS_MCP_ENV_ALLOWLIST_EXTRA"] = old_ops_extra + + def test_mcp_env_allowlist_extra_merges_with_default(self) -> None: + from oclaw.runtime.operations import mcp_env + + old_aia = os.environ.pop("AIA_MCP_ENV_ALLOWLIST", None) + old_extra = os.environ.pop("AIA_MCP_ENV_ALLOWLIST_EXTRA", None) + old_ops_extra = os.environ.pop("OPS_MCP_ENV_ALLOWLIST_EXTRA", None) + try: + os.environ["AIA_MCP_ENV_ALLOWLIST_EXTRA"] = "MY_CUSTOM_MCP_SECRET,CONTEXT7_API_KEY" + keys = mcp_env.mcp_env_allowlist_keys() + self.assertIn("BRAVE_API_KEY", keys) + self.assertIn("CONTEXT7_API_KEY", keys) + self.assertIn("MY_CUSTOM_MCP_SECRET", keys) + self.assertEqual(keys.index("MY_CUSTOM_MCP_SECRET"), len(keys) - 1) + finally: + if old_aia is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST"] = old_aia + if old_extra is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST_EXTRA"] = old_extra + if old_ops_extra is not None: + os.environ["OPS_MCP_ENV_ALLOWLIST_EXTRA"] = old_ops_extra + + def test_mcp_env_allowlist_explicit_replace_plus_extra(self) -> None: + from oclaw.runtime.operations import mcp_env + + old_aia = os.environ.pop("AIA_MCP_ENV_ALLOWLIST", None) + old_extra = os.environ.pop("AIA_MCP_ENV_ALLOWLIST_EXTRA", None) + try: + os.environ["AIA_MCP_ENV_ALLOWLIST"] = "ONLY_A,ONLY_B" + os.environ["AIA_MCP_ENV_ALLOWLIST_EXTRA"] = "ONLY_B,ONLY_C" + keys = mcp_env.mcp_env_allowlist_keys() + self.assertEqual(keys, ["ONLY_A", "ONLY_B", "ONLY_C"]) + self.assertNotIn("BRAVE_API_KEY", keys) + finally: + if old_aia is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST"] = old_aia + else: + os.environ.pop("AIA_MCP_ENV_ALLOWLIST", None) + if old_extra is not None: + os.environ["AIA_MCP_ENV_ALLOWLIST_EXTRA"] = old_extra + else: + os.environ.pop("AIA_MCP_ENV_ALLOWLIST_EXTRA", None) def test_materialize_bailian_webparser_compat_tool(self) -> None: with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: