diff --git a/_local/system.env.example b/_local/system.env.example index 7838af45..1a6dc25a 100644 --- a/_local/system.env.example +++ b/_local/system.env.example @@ -184,10 +184,12 @@ 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_local.env,但要从宿主/Docker 环境透传到 MCP」的变量名。 +# 写入 oclaw/_local/mcp_local.env(或 data/mcp_local.env 等合并路径)的键会自动传入 MCP,不必出现在本列表。 +# 非空则整表替换内置默认补充名(慎用);一般留空即可。 # 【前端】管理后台可维护同名设置。 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_SQLITE_COMMAND 表格附件走 MCP 时的 sqlite 命令路径覆盖。 diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 0b5be5c5..74a9b27f 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -232,13 +232,13 @@ - 生效:`oclaw/tools/mcp/adapter.py` - `AIA_MCP_ENV_ALLOWLIST` - - 默认:未设置时使用内置 allowlist(Brave/Google/GitHub/Context7/DashScope、Trilium MCP 等,见 `mcp_env._DEFAULT_ALLOWLIST`) - - 作用:若**非空**,则整表替换内置默认(仅列出的名可传入 MCP 子进程);用于刻意缩小暴露面 - - 生效:`oclaw/runtime/operations/mcp_env.py` + - 默认:未设置时使用内置补充名单(仅用于**未**出现在 `mcp_local.env` 里、但要从宿主环境透传的变量名,见 `mcp_env._DEFAULT_ALLOWLIST`) + - 作用:**`oclaw/_local/mcp_local.env`(及合并路径)里声明且非空的键**会由 `McpProcessRuntime` 直接传入 MCP,与本项无关;若**非空**设置本项,则整表替换该「补充」默认(不影響 mcp_local 文件中的键) + - 生效:`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` 替换后的列表)之后**追加**变量名,合并去重;新增第三方 MCP 时优先用此项,无需手抄整份默认名单 + - 作用:在「补充主列表」(内置默认,或 `AIA_MCP_ENV_ALLOWLIST` 替换后的列表)之后追加变量名,合并去重;适用于密钥只写在 Docker `-e` / 系统环境、不进 `mcp_local.env` 的情况 - 生效:`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 6b408add..bc93b583 100644 --- a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md +++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md @@ -69,6 +69,7 @@ ### Changed - `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。 ### Removed(OCR 通道) diff --git a/docs/MCP_LOCAL_SERVER.md b/docs/MCP_LOCAL_SERVER.md index dddaae65..c5d708c2 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` 中的同键)。内置默认 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,会自动追加)。 ### Bailian WebSearch(DashScope) - **密钥**:在 `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: empty_response` - **排查顺序**: diff --git a/runtime/operations/mcp_env.py b/runtime/operations/mcp_env.py index 51aeb027..218fc8b7 100644 --- a/runtime/operations/mcp_env.py +++ b/runtime/operations/mcp_env.py @@ -1,6 +1,6 @@ from __future__ import annotations -"""MCP 相关进程环境:默认 allowlist + 可选本地 env 文件(首选 `oclaw/_local/mcp_local.env`)。""" +"""MCP 相关进程环境:``mcp_local.env`` 中的键视为 MCP 专用并传入子进程;allowlist 仅补充宿主环境里未写入该文件的变量名。""" import os from pathlib import Path @@ -29,12 +29,14 @@ def _dedupe_preserve_order(keys: list[str]) -> list[str]: def mcp_env_allowlist_keys() -> list[str]: - """MCP 子进程可继承的环境变量名:主列表 + EXTRA 合并去重。 + """宿主环境 → MCP 子进程的补充白名单(与 ``mcp_local.env`` 中的键取并集)。 - - 未设置 ``AIA_MCP_ENV_ALLOWLIST``:主列表为内置默认(含常用 MCP 密钥名)。 - - 已设置:主列表仅为该项(完全替换默认),用于刻意缩小暴露面。 - - ``AIA_MCP_ENV_ALLOWLIST_EXTRA``(或兼容 ``OPS_MCP_ENV_ALLOWLIST_EXTRA``): - 始终在主列表之后追加,避免为新增 MCP 手抄整份默认名单。 + ``mcp_local.env``(含 ``data/mcp_local.env`` 等合并路径)里**出现且非空**的变量名会始终尝试传入 + MCP 子进程(见 ``McpProcessRuntime._build_runtime_env``),无需出现在本列表中。 + + - 未设置 ``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 = _split_csv_keys(primary_raw) if primary_raw else _split_csv_keys(_DEFAULT_ALLOWLIST) diff --git a/runtime/tools/mcp/runtime.py b/runtime/tools/mcp/runtime.py index 5740f6f0..4a448ba3 100644 --- a/runtime/tools/mcp/runtime.py +++ b/runtime/tools/mcp/runtime.py @@ -25,14 +25,26 @@ class McpProcessRuntime: def _build_runtime_env(env_allowlist: list[str] | None) -> dict[str, str] | None: if env_allowlist is 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"} env: dict[str, str] = {} for k in keep_keys: if k in os.environ: 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: 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] return env diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py index 6dc6a78a..e971bf31 100644 --- a/tests/test_mcp_runtime.py +++ b/tests/test_mcp_runtime.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os import tempfile import textwrap import unittest from pathlib import Path +from unittest.mock import patch from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime @@ -69,6 +71,16 @@ class McpRuntimeTests(unittest.TestCase): finally: 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: script = self._write_server( """