diff --git a/INSTALL.md b/INSTALL.md index 97ae281..8e90bec 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -49,13 +49,13 @@ git -C describe --tags --exact-match HEAD ### 2. 环境检查与确定性准备 -需要:Git、`uv`、Python 3.12+、NocoBase `nb` CLI 2.1+。先运行只读检查: +需要:Git、`uv`、Python 3.12+、NocoBase `nb` CLI 2.1+。Git 只用于取得和校验发布版本;`uv` 用于创建锁定的项目环境以及后续诊断/更新。若机器尚未安装 `nb`,安装官方 CLI 还会条件性需要 Node.js 与 npm。先运行只读检查: ```bash uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root inspect --json ``` -若 `uv` 或 `nb` 不存在,先取得用户同意再安装。NocoBase CLI 当前官方包入口是 `npm install -g @nocobase/cli`;安装后必须重新运行检查,不要通过修改 PATH 输出伪造成功。 +若 `uv`、`nb` 或安装 `nb` 所需的 Node.js/npm 不存在,先向用户说明来源、安装位置和影响范围并取得同意。NocoBase CLI 当前官方包入口是 `npm install -g @nocobase/cli`;安装后必须重新运行检查,不要通过修改 PATH 输出伪造成功。 然后运行幂等准备步骤: @@ -63,7 +63,9 @@ uv run --no-project --python 3.12 python /scripts/agent_install.py uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root prepare --json ``` -该脚本只会校验项目文件、在缺失时从示例创建 `config.json`,并执行锁定依赖同步。已有 `config.json` 会被保留,依赖必须来自 `uv.lock`。 +该脚本只会校验项目文件、在缺失时从示例创建 `config.json`,并把 `uv.lock` 中的**运行时依赖**同步到项目自己的 `.venv`。它使用 `--no-dev`,不会给终端用户安装 `mypy`、`ruff`、`pip-audit` 或其传递依赖。已有 `config.json` 会被保留。 + +安装会在用户电脑留下以下内容:固定 Release 的仓库副本及浅层 `.git`、项目 `.venv`、不含 Key 的 `config.json`、由 `nb` CLI 管理的个人登录环境,以及合并后的 MCP 客户端配置和原配置备份。`uv` 可能保留下载缓存,缓存不是 MCP 运行所必需;安装器不会自动删除用户的共享缓存。默认 stdio 流程不会创建 systemd 服务、监听端口或 `.runtime` Token。 ### 3. 连接用户自己的 NocoBase 账号 @@ -104,7 +106,7 @@ Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐 uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root client-config ``` -输出采用常见的 `mcpServers` JSON 形状,包含绝对的项目、`uv` 和 `nb` 路径。Agent 应查明自己客户端当前版本支持的 MCP 配置格式,将其中的 `command`、`args`、`env` 等价映射过去。 +输出采用常见的 `mcpServers` JSON 形状,包含项目 `.venv` 中 Python、项目配置和 `nb` 的绝对路径。MCP 客户端直接启动已准备好的虚拟环境,不在每次调用时经过 `uv` 或同步依赖。Agent 应查明自己客户端当前版本支持的 MCP 配置格式,将其中的 `command`、`args`、`env` 等价映射过去。 写入前: diff --git a/README.md b/README.md index fabc28d..857bfc8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Each user runs this MCP on their own computer and signs in to the remote NocoBas For an agent-guided installation that works across MCP clients, give the installing Agent the immutable Release version of [`INSTALL.md`](INSTALL.md). The guide combines adaptive prompts with an idempotent helper script; it defaults to local stdio and read-only access. Do not distribute a default-branch installation link as a release. +The end-user installer creates a project-local `.venv` from the locked runtime dependency set only; it does not install the development tools from the `dev` group. Generated MCP client configuration launches that environment's Python directly, so normal MCP startup does not resync packages or require `uv` to remain in the client command. + The default stdio server is read-only. Add `--enable-writes` for fixed-schema project create/update tools. Optional local HTTP mode also provides authenticated attachment uploads. Neither mode exposes arbitrary collections, fields, commands, or delete operations. ## Configure diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index 9af1b92..17b5bb8 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -13,8 +13,10 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程 ## 环境要求 - Python 3.12 或更高版本 +- Git(仅安装、版本校验和更新) - `uv` - NocoBase `nb` CLI 2.1 或更高版本 +- Node.js/npm(仅在尚未安装 `nb` 时用于安装官方 CLI) - 可通过浏览器访问目标 NocoBase - 管理员已为用户配置所需的 NocoBase 角色与数据权限 @@ -31,7 +33,7 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程 ```bash git clone https://github.com/Donaldttt/nocobase-mcp.git cd nocobase-mcp -uv sync --locked +uv sync --locked --no-dev cp config.example.json config.json ``` @@ -47,7 +49,7 @@ Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有 先进行健康检查: ```bash -uv run nocobase-crm-mcp --config config.json +.venv/bin/python -m nocobase_crm_mcp.server --config config.json ``` 然后让 MCP 客户端调用 `crm_health`。如果返回 `nb_auth_failed`,由 Agent 重新启动 `connect-key`,用户在新的本机隐藏提示中输入新 Key;不要让用户手动尝试 `nb` 命令。 @@ -60,21 +62,23 @@ uv run nocobase-crm-mcp --config config.json { "mcpServers": { "nocobase-crm": { - "command": "uv", + "command": "/absolute/path/to/nocobase-mcp/.venv/bin/python", "args": [ - "--directory", - "/absolute/path/to/nocobase-mcp", - "run", - "--no-sync", - "nocobase-crm-mcp", + "-m", + "nocobase_crm_mcp.server", "--config", "/absolute/path/to/nocobase-mcp/config.json" - ] + ], + "env": { + "NOCOBASE_MCP_NB_BIN": "/absolute/path/to/nb" + } } } } ``` +不要手写猜测虚拟环境路径;优先使用 `scripts/agent_install.py client-config` 的输出。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。 + 默认只读。如需项目创建和更新,在参数末尾加入 `--enable-writes`。这些写工具只允许预先定义的项目字段,不提供删除或任意资源访问能力。 附件上传需要可选的本机 HTTP 模式。HTTP 服务默认只监听 `127.0.0.1:10087`;上传路由本身也默认关闭,不得暴露到局域网或公网: @@ -120,8 +124,8 @@ Linux 用户也可以执行 `bash scripts/mcp_service.sh start` 安装用户级 ```bash git rev-parse HEAD git pull --ff-only -uv sync --locked -uv run python -m unittest discover -s tests +uv sync --locked --no-dev +.venv/bin/python -m unittest discover -s tests ``` 使用 systemd 本机 HTTP 服务时,再执行: @@ -136,7 +140,7 @@ journalctl --user -u nocobase-crm-mcp.service -n 200 --no-pager ```bash git switch --detach -uv sync --locked +uv sync --locked --no-dev bash scripts/mcp_service.sh start ``` diff --git a/install-manifest.json b/install-manifest.json index 3037068..119836b 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -15,8 +15,25 @@ "defaultTransport": "stdio", "python": ">=3.12", "packageManager": "uv", + "environment": ".venv", + "launcher": "project-venv-python", + "dependencyProfile": "runtime-only-no-dev", "nocobaseCli": ">=2.1" }, + "installation": { + "requiredTools": ["git", "uv", "python", "nb"], + "conditionalTools": { + "nodeAndNpm": "Required only when the NocoBase nb CLI is not already installed." + }, + "persistentArtifacts": [ + "release checkout", + "project .venv", + "config.json", + "nb CLI user environment", + "MCP client configuration and backup" + ], + "defaultBackgroundService": false + }, "authentication": { "type": "personal-api-key", "cliAuthType": "token", diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 06b5904..07efa2e 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -90,7 +90,7 @@ def create_server( allowed_origins: list[str] | None = None, ) -> Any: if FastMCP is None: - raise RuntimeError("Missing MCP SDK. Install dependencies with: uv sync") + raise RuntimeError("Missing MCP SDK. Install runtime dependencies with: uv sync --locked --no-dev") service = crm_service or _make_service(config_path) if not readonly and project_service is None: raise ValueError("project_service is required when readonly is false") diff --git a/pyproject.toml b/pyproject.toml index 390da9d..f68b2d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.12" dependencies = [ "cryptography>=50,<51", "httpx>=0.27,<1", - "mcp[cli]>=1.27,<2", + "mcp>=1.27,<2", "python-multipart>=0.0.9,<1", ] diff --git a/scripts/agent_installer/diagnostics.py b/scripts/agent_installer/diagnostics.py index 1ee54d3..d059c21 100644 --- a/scripts/agent_installer/diagnostics.py +++ b/scripts/agent_installer/diagnostics.py @@ -13,7 +13,7 @@ from .common import ( shutil, subprocess, ) -from .project import inspect_project +from .project import _venv_python_path, inspect_project def doctor_project( @@ -47,10 +47,7 @@ def doctor_project( commands = ( [uv_bin, "lock", "--check"], [ - uv_bin, - "run", - "--no-sync", - "python", + str(_venv_python_path(root)), "-c", "import mcp; import nocobase_crm_mcp", ], diff --git a/scripts/agent_installer/project.py b/scripts/agent_installer/project.py index 7b49207..a78f991 100644 --- a/scripts/agent_installer/project.py +++ b/scripts/agent_installer/project.py @@ -101,9 +101,19 @@ def prepare_project( shutil.copy2(example_path, config_path) created_config = True - command = [uv_bin, "sync", "--locked", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"] + venv_path = root / ".venv" + command = [uv_bin, "sync", "--locked", "--no-dev", "--python", f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"] + command_environment = os.environ.copy() + command_environment["UV_PROJECT_ENVIRONMENT"] = str(venv_path) try: - result = runner(command, cwd=str(root), capture_output=True, text=True, check=False) + result = runner( + command, + cwd=str(root), + env=command_environment, + capture_output=True, + text=True, + check=False, + ) except OSError as error: raise InstallerError(f"Could not execute uv sync: {error}") from error if result.returncode != 0: @@ -116,7 +126,8 @@ def prepare_project( "projectRoot": str(root), "configPath": str(config_path), "configCreated": created_config, - "dependencies": "locked", + "dependencies": "locked-runtime-only", + "environmentPath": str(venv_path), } @@ -169,20 +180,16 @@ def render_client_config( *, enable_writes: bool = False, which: Which = shutil.which, + os_name: str | None = None, ) -> dict[str, Any]: root = _project_root(project_root) - uv_bin = which("uv") or "uv" nb_bin = which("nb") or "nb" - if os.path.isabs(uv_bin): - uv_bin = os.path.abspath(uv_bin) if os.path.isabs(nb_bin): nb_bin = os.path.abspath(nb_bin) + python_bin = _venv_python_path(root, os_name=os_name) args = [ - "--directory", - str(root), - "run", - "--no-sync", - "nocobase-crm-mcp", + "-m", + "nocobase_crm_mcp.server", "--config", str(root / "config.json"), ] @@ -191,9 +198,15 @@ def render_client_config( return { "mcpServers": { SERVER_NAME: { - "command": uv_bin, + "command": str(python_bin), "args": args, "env": {"NOCOBASE_MCP_NB_BIN": nb_bin}, } } } + + +def _venv_python_path(root: Path, *, os_name: str | None = None) -> Path: + if (os_name or os.name) == "nt": + return root / ".venv" / "Scripts" / "python.exe" + return root / ".venv" / "bin" / "python" diff --git a/tests/test_agent_install.py b/tests/test_agent_install.py index 7e67da6..d061f44 100644 --- a/tests/test_agent_install.py +++ b/tests/test_agent_install.py @@ -73,7 +73,10 @@ class AgentInstallTests(unittest.TestCase): self.assertTrue(first["configCreated"]) self.assertFalse(second["configCreated"]) self.assertEqual(preserved["env"], "custom-env") - self.assertEqual(calls[0][0], ["/opt/bin/uv", "sync", "--locked", "--python", "3.12"]) + self.assertEqual(calls[0][0], ["/opt/bin/uv", "sync", "--locked", "--no-dev", "--python", "3.12"]) + self.assertEqual(calls[0][1]["env"]["UV_PROJECT_ENVIRONMENT"], str(root / ".venv")) + self.assertEqual(first["dependencies"], "locked-runtime-only") + self.assertEqual(first["environmentPath"], str(root / ".venv")) self.assertEqual(len(calls), 2) def test_set_env_backs_up_config_before_changing_it(self) -> None: @@ -227,12 +230,21 @@ class AgentInstallTests(unittest.TestCase): writable = agent_install.render_client_config(root, enable_writes=True, which=fake_which) server = config["mcpServers"]["nocobase-crm"] - self.assertEqual(server["command"], "/opt/bin/uv") + self.assertEqual(server["command"], str(root.resolve() / ".venv" / "bin" / "python")) self.assertEqual(server["env"]["NOCOBASE_MCP_NB_BIN"], "/opt/bin/nb") - self.assertIn(str(root.resolve()), server["args"]) + self.assertIn(str(root.resolve() / "config.json"), server["args"]) + self.assertEqual(server["args"][:2], ["-m", "nocobase_crm_mcp.server"]) self.assertNotIn("--enable-writes", server["args"]) self.assertIn("--enable-writes", writable["mcpServers"]["nocobase-crm"]["args"]) + def test_client_config_uses_windows_project_environment_python(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = agent_install.render_client_config(root, which=fake_which, os_name="nt") + + server = config["mcpServers"]["nocobase-crm"] + self.assertEqual(server["command"], str(root.resolve() / ".venv" / "Scripts" / "python.exe")) + def test_doctor_checks_local_install_without_reading_nb_secrets(self) -> None: commands = [] @@ -254,6 +266,14 @@ class AgentInstallTests(unittest.TestCase): self.assertTrue(result["ok"]) self.assertEqual(commands[0], ["/opt/bin/uv", "lock", "--check"]) + self.assertEqual( + commands[1], + [ + str(root.resolve() / ".venv" / "bin" / "python"), + "-c", + "import mcp; import nocobase_crm_mcp", + ], + ) self.assertNotIn("--show-secrets", json.dumps(result)) self.assertTrue(any("crm_health" in step for step in result["remainingVerification"])) @@ -269,6 +289,9 @@ class AgentInstallTests(unittest.TestCase): self.assertEqual(manifest["authentication"]["secretEntry"], "user-controlled-private-tty") self.assertFalse(manifest["authentication"]["allowPassword"]) self.assertFalse(manifest["authentication"]["allowCommandLineSecret"]) + self.assertEqual(manifest["runtime"]["launcher"], "project-venv-python") + self.assertEqual(manifest["runtime"]["dependencyProfile"], "runtime-only-no-dev") + self.assertFalse(manifest["installation"]["defaultBackgroundService"]) self.assertTrue(manifest["securityDefaults"]["readOnly"]) self.assertFalse(manifest["securityDefaults"]["uploads"]) diff --git a/uv.lock b/uv.lock index d5863c8..64fa5c8 100644 --- a/uv.lock +++ b/uv.lock @@ -10,15 +10,6 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'win32'", ] -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -639,12 +630,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -765,7 +750,7 @@ source = { editable = "." } dependencies = [ { name = "cryptography" }, { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, + { name = "mcp" }, { name = "python-multipart" }, ] @@ -780,7 +765,7 @@ dev = [ requires-dist = [ { name = "cryptography", specifier = ">=50,<51" }, { name = "httpx", specifier = ">=0.27,<1" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.27,<2" }, + { name = "mcp", specifier = ">=1.27,<2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, ] @@ -1239,15 +1224,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1337,21 +1313,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] -[[package]] -name = "typer" -version = "0.26.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0"