feat(mcp): pass all mcp_local.env keys to subprocess; allowlist supplements host-only

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-12 18:51:07 +08:00
parent 48d5e468e6
commit 274a9b8ddd
7 changed files with 44 additions and 15 deletions

View file

@ -184,10 +184,12 @@ AIA_LOCAL_ADAPTER_STARTUP_SELF_CHECK=1
# AIA_MCP_SPECIALISTS 允许使用 MCP 的专家角色列表,逗号分隔。 # AIA_MCP_SPECIALISTS 允许使用 MCP 的专家角色列表,逗号分隔。
AIA_MCP_SPECIALISTS=generalist,manager AIA_MCP_SPECIALISTS=generalist,manager
# AIA_MCP_ENV_ALLOWLIST 允许注入 MCP 子进程的环境变量名列表(非空则整表替换内置默认,慎用)。 # AIA_MCP_ENV_ALLOWLIST 补充名单:仅用于「未写入 mcp_local.env,但要从宿主/Docker 环境透传到 MCP」的变量名。
# 写入 oclaw/_local/mcp_local.env(或 data/mcp_local.env 等合并路径)的键会自动传入 MCP,不必出现在本列表。
# 非空则整表替换内置默认补充名(慎用);一般留空即可。
# 【前端】管理后台可维护同名设置。 # 【前端】管理后台可维护同名设置。
AIA_MCP_ENV_ALLOWLIST= AIA_MCP_ENV_ALLOWLIST=
# AIA_MCP_ENV_ALLOWLIST_EXTRA 在默认名单(或未设置 AIA_MCP_ENV_ALLOWLIST 时的内置默认)之后追加的变量名,逗号分隔;不必重复抄写默认里的键。 # AIA_MCP_ENV_ALLOWLIST_EXTRA 在「内置默认补充名」或上面整表替换结果之后,再追加的补充变量名,逗号分隔。
AIA_MCP_ENV_ALLOWLIST_EXTRA= AIA_MCP_ENV_ALLOWLIST_EXTRA=
# AIA_MCP_SQLITE_COMMAND 表格附件走 MCP 时的 sqlite 命令路径覆盖。 # AIA_MCP_SQLITE_COMMAND 表格附件走 MCP 时的 sqlite 命令路径覆盖。

View file

@ -232,13 +232,13 @@
- 生效:`oclaw/tools/mcp/adapter.py` - 生效:`oclaw/tools/mcp/adapter.py`
- `AIA_MCP_ENV_ALLOWLIST` - `AIA_MCP_ENV_ALLOWLIST`
- 默认:未设置时使用内置 allowlist(Brave/Google/GitHub/Context7/DashScope、Trilium MCP 等,见 `mcp_env._DEFAULT_ALLOWLIST`) - 默认:未设置时使用内置补充名单(仅用于**未**出现在 `mcp_local.env` 里、但要从宿主环境透传的变量名,见 `mcp_env._DEFAULT_ALLOWLIST`)
- 作用:若**非空**,则整表替换内置默认(仅列出的名可传入 MCP 子进程);用于刻意缩小暴露面 - 作用:**`oclaw/_local/mcp_local.env`(及合并路径)里声明且非空的键**会由 `McpProcessRuntime` 直接传入 MCP,与本项无关;若**非空**设置本项,则整表替换该「补充」默认(不影響 mcp_local 文件中的键)
- 生效:`oclaw/runtime/operations/mcp_env.py` - 生效:`oclaw/runtime/operations/mcp_env.py`、`oclaw/runtime/tools/mcp/runtime.py`
- `AIA_MCP_ENV_ALLOWLIST_EXTRA`(兼容 `OPS_MCP_ENV_ALLOWLIST_EXTRA`) - `AIA_MCP_ENV_ALLOWLIST_EXTRA`(兼容 `OPS_MCP_ENV_ALLOWLIST_EXTRA`)
- 默认:空 - 默认:空
- 作用:在「当前主列表」(内置默认,或 `AIA_MCP_ENV_ALLOWLIST` 替换后的列表)之后**追加**变量名,合并去重;新增第三方 MCP 时优先用此项,无需手抄整份默认名单 - 作用:在「补充主列表」(内置默认,或 `AIA_MCP_ENV_ALLOWLIST` 替换后的列表)之后追加变量名,合并去重;适用于密钥只写在 Docker `-e` / 系统环境、不进 `mcp_local.env` 的情况
- 生效:`oclaw/runtime/operations/mcp_env.py` - 生效:`oclaw/runtime/operations/mcp_env.py`
- `AIA_MCP_FILESYSTEM_EXTRA_ROOTS` - `AIA_MCP_FILESYSTEM_EXTRA_ROOTS`

View file

@ -69,6 +69,7 @@
### Changed ### Changed
- `mcp_env.mcp_env_allowlist_keys()`:除「`AIA_MCP_ENV_ALLOWLIST` 非空则整表替换内置默认」外,`EXTRA` 始终追加到当前主表之后。 - `mcp_env.mcp_env_allowlist_keys()`:除「`AIA_MCP_ENV_ALLOWLIST` 非空则整表替换内置默认」外,`EXTRA` 始终追加到当前主表之后。
- **MCP 子进程环境**:`mcp_local.env`(合并路径)中**声明且非空**的键一律传入 MCP,与 allowlist 取并集;allowlist 仅补充「只存在于宿主环境、未写入 mcp_local 文件」的变量名。
- `send_ocr_image_messages` 未配 `AIA_OCR_MODEL`(且未传 `model`)失败;图片专家 **`send_legacy_image_messages`** 首选 **用户所选会话/专家绑定的模型的 `model`/`base_url`/`api_key`**,缺省时再回落 **`AIA_IMAGE_EXPERT_*`**(不读取 `AIA_OCR_*`);服务端若模型不支持看图则直接报错,不做备用 payload。 - `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 通道) ### Removed(OCR 通道)

View file

@ -505,13 +505,13 @@ Keep responses deterministic and JSON-serializable.
- **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。 - **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。
- **安装**:`python scripts/install_mcp_context7.py`,或管理台 `POST /admin/api/mcp/install` 使用 [`examples/mcp_install_context7.json`](../examples/mcp_install_context7.json) 中的 `payload`。 - **安装**:`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` 中的同键)。内置默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/runtime/operations/mcp_env.py`);若你用 **`AIA_MCP_ENV_ALLOWLIST` 整表替换**默认,须在该列表里显式保留 `CONTEXT7_API_KEY`,或改用 **`AIA_MCP_ENV_ALLOWLIST_EXTRA`** 只追加新变量名而不动默认集合。 - **密钥**:在 **`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` 中的同键)。**写入任一合并 `mcp_local.env` 的键会自动传入 MCP 子进程**;若密钥只配在宿主/Docker 环境、不进文件,才依赖内置或自定义的 `AIA_MCP_ENV_ALLOWLIST` 补充名单。
- **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。 - **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。
### Bailian WebSearch(DashScope) ### Bailian WebSearch(DashScope)
- **密钥**:在 `oclaw/_local/mcp_local.env`(推荐)设置 `DASHSCOPE_API_KEY=...`。 - **密钥**:在 `oclaw/_local/mcp_local.env`(推荐)设置 `DASHSCOPE_API_KEY=...`。
- **关键注意**:若使用 **`AIA_MCP_ENV_ALLOWLIST` 整表替换**内置默认,须在该列表里显式包含 `DASHSCOPE_API_KEY`,否则 MCP 子进程拿不到该密钥。更省事的做法是:**不要设置** `AIA_MCP_ENV_ALLOWLIST`,只在 **`AIA_MCP_ENV_ALLOWLIST_EXTRA`** 里追加其它 MCP 需要的变量名(与默认名单自动合并)。常见表现是: - **关键注意**:密钥写在 **`mcp_local.env` 里即可传入 MCP**。若 **`DASHSCOPE_API_KEY` 只存在于宿主环境**、未写入 `mcp_local.env`,须确保其出现在 **`AIA_MCP_ENV_ALLOWLIST` 默认或自定义补充名单**中(或用 **`AIA_MCP_ENV_ALLOWLIST_EXTRA`** 追加)。常见表现是:
- `error_code: mcp_runtime_empty_response` - `error_code: mcp_runtime_empty_response`
- `error: empty_response` - `error: empty_response`
- **排查顺序**: - **排查顺序**:

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
"""MCP 相关进程环境:默认 allowlist + 可选本地 env 文件(首选 `oclaw/_local/mcp_local.env`)。""" """MCP 相关进程环境:``mcp_local.env`` 中的键视为 MCP 专用并传入子进程;allowlist 仅补充宿主环境里未写入该文件的变量名。"""
import os import os
from pathlib import Path from pathlib import Path
@ -29,12 +29,14 @@ def _dedupe_preserve_order(keys: list[str]) -> list[str]:
def mcp_env_allowlist_keys() -> list[str]: def mcp_env_allowlist_keys() -> list[str]:
"""MCP 子进程可继承的环境变量名:主列表 + EXTRA 合并去重。 """宿主环境 → MCP 子进程的补充白名单(与 ``mcp_local.env`` 中的键取并集)。
- 未设置 ``AIA_MCP_ENV_ALLOWLIST``:主列表为内置默认(含常用 MCP 密钥名)。 ``mcp_local.env``(含 ``data/mcp_local.env`` 等合并路径)里**出现且非空**的变量名会始终尝试传入
- 已设置:主列表仅为该项(完全替换默认),用于刻意缩小暴露面。 MCP 子进程(见 ``McpProcessRuntime._build_runtime_env``),无需出现在本列表中。
- ``AIA_MCP_ENV_ALLOWLIST_EXTRA``(或兼容 ``OPS_MCP_ENV_ALLOWLIST_EXTRA``):
始终在主列表之后追加,避免为新增 MCP 手抄整份默认名单。 - 未设置 ``AIA_MCP_ENV_ALLOWLIST``:本列表为内置默认(常用仅写在 Docker/系统 env 的密钥名)。
- 已设置:本列表仅为该项(完全替换内置默认),用于补充「未写入 mcp_local 文件」的透传名。
- ``AIA_MCP_ENV_ALLOWLIST_EXTRA``(或 ``OPS_MCP_ENV_ALLOWLIST_EXTRA``):始终追加到本列表之后,合并去重。
""" """
primary_raw = str(os.getenv("AIA_MCP_ENV_ALLOWLIST") or "").strip() 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) primary = _split_csv_keys(primary_raw) if primary_raw else _split_csv_keys(_DEFAULT_ALLOWLIST)

View file

@ -25,14 +25,26 @@ class McpProcessRuntime:
def _build_runtime_env(env_allowlist: list[str] | None) -> dict[str, str] | None: def _build_runtime_env(env_allowlist: list[str] | None) -> dict[str, str] | None:
if env_allowlist is None: if env_allowlist is None:
return None return None
from oclaw.runtime.operations.mcp_env import mcp_local_env_merged
keep_keys = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE"} keep_keys = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE"}
env: dict[str, str] = {} env: dict[str, str] = {}
for k in keep_keys: for k in keep_keys:
if k in os.environ: if k in os.environ:
env[k] = os.environ[k] env[k] = os.environ[k]
# Keys declared in mcp_local.env (any merged path): treat as MCP-scoped; always pass
# through when non-empty (values come from os.environ after gateway merge, else file literal).
for k, v in mcp_local_env_merged().items():
if not str(v or "").strip():
continue
live = str(os.environ.get(k, "") or "").strip()
if live:
env[k] = os.environ[k]
else:
env[k] = str(v).strip()
for k in env_allowlist: for k in env_allowlist:
key = str(k or "").strip() key = str(k or "").strip()
if key and key in os.environ: if key and key in os.environ and str(os.environ[key] or "").strip():
env[key] = os.environ[key] env[key] = os.environ[key]
return env return env

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
import tempfile import tempfile
import textwrap import textwrap
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime
@ -69,6 +71,16 @@ class McpRuntimeTests(unittest.TestCase):
finally: finally:
rt.stop() rt.stop()
@patch("oclaw.runtime.operations.mcp_env.mcp_local_env_merged", return_value={"MCP_ONLY_FROM_FILE": "fileval"})
def test_mcp_local_env_keys_passed_without_allowlist_name(self, _mock_merged: object) -> None:
os.environ["MCP_ONLY_FROM_FILE"] = "liveval"
try:
env = McpProcessRuntime._build_runtime_env([])
assert env is not None
self.assertEqual(env.get("MCP_ONLY_FROM_FILE"), "liveval")
finally:
os.environ.pop("MCP_ONLY_FROM_FILE", None)
def test_empty_allowlist_keeps_path_for_subprocess(self) -> None: def test_empty_allowlist_keeps_path_for_subprocess(self) -> None:
script = self._write_server( script = self._write_server(
""" """