diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..07a2f75 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build distribution + run: python -m pip install --upgrade build twine && python -m build + - name: Validate distribution metadata + run: python -m twine check dist/* + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload dist/* diff --git a/README.md b/README.md index 148c511..519eda2 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Conversation flow: - [Technical Docs Index](docs/README.md) - [Chinese Docs Index](docs/README.zh-CN.md) - [Codebase Reading Guide](docs/CODEBASE_READING_GUIDE.md) +- [MCP Client Configuration](docs/MCP.md) ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index a00c5e3..ab057f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -143,6 +143,7 @@ FirstCoder 的 TUI 不是为了把 agent loop 藏起来,而是为了把它展 - [技术文档入口](docs/README.zh-CN.md) - [English Docs Index](docs/README.md) - [代码阅读指南](docs/CODEBASE_READING_GUIDE.zh-CN.md) +- [MCP 客户端配置](docs/MCP.zh-CN.md) ## 开发 diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..3722c49 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,83 @@ +# MCP Client + +FirstCoder can discover tools from configured MCP servers and expose them to the +agent through the existing tool registry. MCP is an extension boundary for +tools only: discovered tools are named `mcp____` and always pass +through the normal permission manager. + +## Configuration + +Put server definitions in either global `~/.config/firstcoder/config.toml` or +project `./firstcoder.toml`. A project definition with the same server name +completely replaces the global definition. + +```toml +[mcp.local_echo] +type = "local" +command = ["python", "-m", "my_mcp_server"] +enabled = true +timeout_ms = 5000 +env = { SERVICE_TOKEN = "{env:SERVICE_TOKEN}" } +allowed_tools = ["echo", "files_*"] + +[mcp.company] +type = "remote" +url = "https://mcp.example.com/mcp" +headers = { Authorization = "Bearer {env:COMPANY_MCP_TOKEN}" } +enabled = true +timeout_ms = 8000 +``` + +`local` launches a stdio server without a shell. Its configured environment is +added to the host environment, so the command can still find `PATH`. `remote` +uses the MCP SDK Streamable HTTP client and forwards the configured URL and +headers. `allowed_tools` is optional and accepts tool-name glob patterns. + +Use `{env:NAME}` for credentials rather than putting them in configuration. +Placeholders are resolved only while connecting; if one is absent, that server +fails safely and the message identifies only the variable name, never its +value. + +## Permissions and status + +Every MCP call has the `mcp_tool` permission action and an exact target of +`/`. In standard and aggressive modes it pauses for confirmation +by default; bypass mode is the sole automatic path. An explicit “allow always” +grant is limited to that exact server/tool pair. + +Use these commands in the TUI or interactive client: + +```text +/mcp list +/mcp doctor +``` + +They show connection state, discovered tool count, and safe errors. They do +not print configured headers, resolved environment values, or other secrets. +A failed, disabled, or timed-out server does not block startup and contributes +no tools. + +## Troubleshooting + +- Confirm the command works as an MCP stdio server when run independently; + ordinary logs must go to stderr, not stdout. +- Check that the configured `command` is an argv list, the remote URL is HTTP + or HTTPS, and server/tool names contain only letters, numbers, `_`, or `-`. +- Run `/mcp doctor ` after changing configuration. Restart FirstCoder + to reconnect: connection state is process-local and is not stored in a + session. +- If a tool is missing, inspect `allowed_tools` and name collisions with + built-in or another MCP tool. If a secret placeholder is missing, export the + named variable before launch. + +## Deliberately unsupported + +This client does not implement MCP resources, prompts, sampling, roots, +elicitation, OAuth, or a plugin marketplace/installation system. It also does +not alter FirstCoder's built-in `web_search` tool. + +## Verification + +```sh +.venv/bin/python -m pytest tests/test_mcp_integration.py -q +``` diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md new file mode 100644 index 0000000..e64461e --- /dev/null +++ b/docs/MCP.zh-CN.md @@ -0,0 +1,66 @@ +# MCP 客户端 + +FirstCoder 可以从配置的 MCP server 发现工具,并通过现有工具注册表提供给 agent。MCP +在这里仅扩展“工具”:发现到的工具命名为 `mcp____`,并且始终经过原有权限管理器。 + +## 配置 + +可以在全局 `~/.config/firstcoder/config.toml` 或项目 `./firstcoder.toml` +中定义 server。项目里同名 server 会完整覆盖全局定义。 + +```toml +[mcp.local_echo] +type = "local" +command = ["python", "-m", "my_mcp_server"] +enabled = true +timeout_ms = 5000 +env = { SERVICE_TOKEN = "{env:SERVICE_TOKEN}" } +allowed_tools = ["echo", "files_*"] + +[mcp.company] +type = "remote" +url = "https://mcp.example.com/mcp" +headers = { Authorization = "Bearer {env:COMPANY_MCP_TOKEN}" } +enabled = true +timeout_ms = 8000 +``` + +`local` 以 stdio 启动 server,不经过 shell;配置的环境变量会叠加到宿主环境, +不会丢失 `PATH`。`remote` 使用 MCP SDK 的 Streamable HTTP client,并转发配置的 +URL 与 headers。`allowed_tools` 可选,支持工具名 glob 过滤。 + +凭证请使用 `{env:NAME}`,不要直接写进配置。占位符只在真正连接时解析;变量缺失时, +对应 server 会安全失败,错误只会指出变量名,绝不会显示变量值。 + +## 权限与状态 + +每次 MCP 调用都使用 `mcp_tool` 权限动作,目标精确为 `/`。标准模式和 +激进模式默认都会暂停等待确认;只有 bypass 模式会自动放行。“始终允许”也仅适用于 +这个精确的 server/tool 对。 + +在 TUI 或行式交互客户端中使用: + +```text +/mcp list +/mcp doctor +``` + +它们会显示连接状态、发现工具数和安全错误,不会输出配置 headers、已解析的环境变量 +或其他秘密。server 失败、禁用或超时都不会阻止 FirstCoder 启动,只是不注入工具。 + +## 排障 + +- 先独立确认命令能作为 MCP stdio server 运行;普通日志必须写到 stderr,不能污染 stdout。 +- 确认 `command` 是 argv 列表,remote URL 是 HTTP/HTTPS,server/tool 名只能包含字母、数字、`_`、`-`。 +- 修改配置后用 `/mcp doctor ` 检查;重启 FirstCoder 才会重新连接,因为连接状态只存在进程内,不写入 session。 +- 工具缺失时检查 `allowed_tools`,以及与内建或其他 MCP 工具的命名冲突。缺失秘密占位符时,在启动前 export 错误中点名的变量。 + +## 明确不支持 + +当前不实现 MCP resources、prompts、sampling、roots、elicitation、OAuth、插件市场或插件安装体系,也不会修改 FirstCoder 内建的 `web_search` 工具。 + +## 验证 + +```sh +.venv/bin/python -m pytest tests/test_mcp_integration.py -q +``` diff --git a/docs/README.md b/docs/README.md index 6b422d7..7e38a97 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ mental model, not memorize a directory tree. | How are function schemas and executors connected? | [Tools Design](TOOLS_DESIGN.md) / [中文](TOOLS_DESIGN.zh-CN.md) | | How are OpenAI-compatible and Anthropic protocols normalized? | [Providers Design](PROVIDERS_DESIGN.md) / [中文](PROVIDERS_DESIGN.zh-CN.md) | | How are local skills found and safely loaded? | [Skill System Design](SKILL_SYSTEM_DESIGN.md) / [中文](SKILL_SYSTEM_DESIGN.zh-CN.md) | +| How are external MCP tools configured and permissioned? | [MCP Client](MCP.md) / [中文](MCP.zh-CN.md) | ## Evaluation And Operations diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 220a9d3..2bc6e4f 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -28,6 +28,7 @@ | 函数 schema 和本地执行器怎样对应? | [工具设计](TOOLS_DESIGN.zh-CN.md) / [English](TOOLS_DESIGN.md) | | 多家模型协议怎样被统一? | [Provider 设计](PROVIDERS_DESIGN.zh-CN.md) / [English](PROVIDERS_DESIGN.md) | | 本地 Skill 怎样发现、路由和安全加载? | [Skill 系统设计](SKILL_SYSTEM_DESIGN.zh-CN.md) / [English](SKILL_SYSTEM_DESIGN.md) | +| 外部 MCP 工具怎样配置并经过权限控制? | [MCP 客户端](MCP.zh-CN.md) / [English](MCP.md) | ## 评测与运行手册 diff --git a/firstcoder/agent/prompt_inputs.py b/firstcoder/agent/prompt_inputs.py index 8c3f299..f3ecdc8 100644 --- a/firstcoder/agent/prompt_inputs.py +++ b/firstcoder/agent/prompt_inputs.py @@ -21,6 +21,7 @@ "delete": "confirm", "shell": "confirm", "network": "confirm", + "mcp_tools": "confirm", "env_secrets": "redact", } diff --git a/firstcoder/app/factory.py b/firstcoder/app/factory.py index 601f54a..ea59aab 100644 --- a/firstcoder/app/factory.py +++ b/firstcoder/app/factory.py @@ -3,11 +3,14 @@ from __future__ import annotations from pathlib import Path +from collections.abc import Callable +from typing import Protocol from firstcoder.agent.loop_limits import AgentLoopLimits from firstcoder.agent.session import AgentSession, create_project_permission_manager from firstcoder.app.commands import ContextCommandHandler from firstcoder.app.help_commands import HelpCommandHandler +from firstcoder.app.mcp_commands import McpCommandHandler from firstcoder.app.model_commands import ModelCommandHandler, ModelState from firstcoder.app.permission_commands import PermissionCommandHandler from firstcoder.app.router import CompositeCommandHandler @@ -21,6 +24,10 @@ from firstcoder.context.manager import ContextWindowManager from firstcoder.context.provider_summarizer import ProviderLlmCompactSummarizer from firstcoder.context.store import JsonlSessionStore +from firstcoder.mcp.adapter import adapt_mcp_tool +from firstcoder.mcp.config import load_mcp_configs +from firstcoder.mcp.manager import McpManager +from firstcoder.mcp.models import McpServerStatus, McpToolDescription from firstcoder.providers.base import ChatProvider from firstcoder.providers.factory import ProviderConfigError, create_provider, create_provider_from_config from firstcoder.providers.presets import PROVIDER_PRESETS @@ -36,6 +43,47 @@ from firstcoder.utils.sandbox_access import SandboxAccess +class McpManagerLike(Protocol): + """Factory-level MCP lifecycle and discovery boundary.""" + + def connect_all(self) -> None: ... + + def tools(self) -> tuple[tuple[str, McpToolDescription], ...]: ... + + def statuses(self) -> tuple[McpServerStatus, ...]: ... + + def doctor(self, name: str) -> McpServerStatus | None: ... + + def close(self) -> None: ... + + +class McpToolProvider: + """Merge a stable base tool set with the manager's current MCP catalog.""" + + def __init__(self, base_tools: list[Tool], manager: McpManagerLike, *, include_mcp: bool) -> None: + self._base_tools = list(base_tools) + self._manager = manager + self._include_mcp = include_mcp + + def __call__(self) -> list[Tool]: + tools = list(self._base_tools) + if not self._include_mcp: + return tools + names = {tool.name for tool in tools} + try: + catalog = self._manager.tools() + except Exception: + return tools + for server, discovered_tool in catalog: + try: + tool = adapt_mcp_tool(self._manager, server, discovered_tool, existing_names=names) + except ValueError: + continue + tools.append(tool) + names.add(tool.name) + return tools + + def create_firstcoder_app( *, project_root: str | Path = ".", @@ -45,6 +93,7 @@ def create_firstcoder_app( tools: list[Tool] | None = None, config: FirstCoderTuiConfig | None = None, app_config: AppConfig | None = None, + mcp_manager_factory: Callable[[tuple], McpManagerLike] | None = None, ) -> FirstCoderApp: """组装可运行的 FirstCoder TUI。 @@ -64,6 +113,13 @@ def create_firstcoder_app( include_network_tools=True, access=sandbox_access, ).tools() + mcp_manager = (mcp_manager_factory or McpManager)(load_mcp_configs(resolved_app_config)) + try: + mcp_manager.connect_all() + except Exception: + pass + tool_provider = McpToolProvider(resolved_tools, mcp_manager, include_mcp=tools is None) + current_tools = tool_provider() resolved_provider = provider or create_provider(project_root=project_path) grant_store = FilePermissionGrantStore(resolved_data_root / "permissions.json") permission_manager = create_project_permission_manager(project_path, grants=grant_store) @@ -71,7 +127,7 @@ def create_firstcoder_app( store=store, session_id=session_id or new_session_id(), project_root=project_path, - tools=resolved_tools, + tools=current_tools, permission_manager=permission_manager, sandbox_access=sandbox_access, ) @@ -89,7 +145,7 @@ def create_firstcoder_app( store=store, project_root=project_path, data_root=resolved_data_root, - tools=resolved_tools, + tools_provider=tool_provider, sandbox_access=sandbox_access, catalog=catalog, ) @@ -97,14 +153,14 @@ def create_firstcoder_app( store=store, project_root=project_path, data_root=resolved_data_root, - tools=resolved_tools, + tools_provider=tool_provider, sandbox_access=sandbox_access, ) fork_service = ForkSessionService( store=store, project_root=project_path, data_root=resolved_data_root, - tools=resolved_tools, + tools_provider=tool_provider, sandbox_access=sandbox_access, catalog=catalog, ) @@ -125,7 +181,8 @@ def create_firstcoder_app( chat_runner = AgentChatRunner( current_session=current, provider=resolved_provider, - tools=resolved_tools, + tools=current_tools, + tools_provider=tool_provider, context_manager=context_manager, limits=AgentLoopLimits.default(), use_streaming=_should_use_streaming(resolved_provider, resolved_app_config), @@ -138,6 +195,7 @@ def create_firstcoder_app( command_handler = CompositeCommandHandler( [ HelpCommandHandler(), + McpCommandHandler(mcp_manager), ModelCommandHandler(model_switcher), session_handler, context_handler, @@ -155,6 +213,7 @@ def create_firstcoder_app( provider_model=resolved_provider.model, project_name=project_path.resolve().name, ), + on_shutdown=mcp_manager.close, ) diff --git a/firstcoder/app/help_commands.py b/firstcoder/app/help_commands.py index e88e5b1..5458e60 100644 --- a/firstcoder/app/help_commands.py +++ b/firstcoder/app/help_commands.py @@ -25,6 +25,8 @@ ("/compact", "Compact context now."), ("/mode", "Show permission mode."), ("/mode ", "Change permission mode."), + ("/mcp list", "List MCP server status."), + ("/mcp doctor ", "Inspect one MCP server."), ] diff --git a/firstcoder/app/mcp_commands.py b/firstcoder/app/mcp_commands.py new file mode 100644 index 0000000..6c5042a --- /dev/null +++ b/firstcoder/app/mcp_commands.py @@ -0,0 +1,49 @@ +"""MCP slash command handlers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from firstcoder.app.commands import CommandResult +from firstcoder.mcp.models import McpServerStatus + + +class McpStatusProvider(Protocol): + """MCP commands need only safe status snapshots.""" + + def statuses(self) -> tuple[McpServerStatus, ...]: ... + + def doctor(self, name: str) -> McpServerStatus | None: ... + + +@dataclass(slots=True) +class McpCommandHandler: + """Handle the small, read-only MCP command surface.""" + + manager: McpStatusProvider + + def handle(self, text: str) -> CommandResult: + parts = text.strip().split() + if not parts or parts[0] != "/mcp": + return CommandResult(handled=False) + if parts == ["/mcp", "list"]: + statuses = self.manager.statuses() + lines = ["MCP servers:"] + lines.extend(_render_status(status) for status in statuses) + if not statuses: + lines.append("- none configured") + return CommandResult(handled=True, output="\n".join(lines)) + if len(parts) == 3 and parts[1] == "doctor": + status = self.manager.doctor(parts[2]) + if status is None: + return CommandResult(handled=True, output=f"Unknown MCP server: {parts[2]}") + return CommandResult(handled=True, output=f"MCP {_render_status(status)}") + if len(parts) == 2 and parts[1] == "doctor": + return CommandResult(handled=True, output="Usage: /mcp doctor ") + return CommandResult(handled=True, output="Usage: /mcp list | /mcp doctor ") + + +def _render_status(status: McpServerStatus) -> str: + text = f"{status.name}: {status.state} ({status.tool_count} tools)" + return f"{text} - error" if status.error else text diff --git a/firstcoder/app/runtime.py b/firstcoder/app/runtime.py index 8fbf311..b4096f8 100644 --- a/firstcoder/app/runtime.py +++ b/firstcoder/app/runtime.py @@ -78,6 +78,7 @@ class AgentChatRunner: current_session: CurrentSessionState provider: ChatProvider tools: list[Tool] | None = None + tools_provider: Callable[[], list[Tool]] | None = None context_builder: ContextBuilder | None = None context_manager: Any | None = None limits: AgentLoopLimits | None = None @@ -135,7 +136,7 @@ def run_user_turn(self, content: str) -> ChatResponse: loop = AgentLoop( session=self.current_session.session, provider=self.provider, - tools=self.tools, + tools=self._current_tools(), context_builder=self.context_builder, context_manager=self.context_manager, limits=self.limits, @@ -179,7 +180,7 @@ def resume_with_user_input(self, request_id: str, answer: str) -> ChatResponse: loop = AgentLoop( session=self.current_session.session, provider=self.provider, - tools=self.tools, + tools=self._current_tools(), context_builder=self.context_builder, context_manager=self.context_manager, limits=self.limits, @@ -226,7 +227,7 @@ async def arun_user_turn(self, content: str) -> ChatResponse: loop = AgentLoop( session=self.current_session.session, provider=self.provider, - tools=self.tools, + tools=self._current_tools(), context_builder=self.context_builder, context_manager=self.context_manager, limits=self.limits, @@ -265,7 +266,7 @@ async def aresume_with_user_input(self, request_id: str, answer: str) -> ChatRes loop = AgentLoop( session=self.current_session.session, provider=self.provider, - tools=self.tools, + tools=self._current_tools(), context_builder=self.context_builder, context_manager=self.context_manager, limits=self.limits, @@ -304,6 +305,11 @@ async def aresume_with_user_input(self, request_id: str, answer: str) -> ChatRes return await asyncio.to_thread(self.resume_with_user_input, request_id, answer) + def _current_tools(self) -> list[Tool] | None: + """Resolve tools once per loop so the session registry sees that same list.""" + + return self.tools_provider() if self.tools_provider is not None else self.tools + def _legacy_max_tool_rounds_kwargs(self) -> dict[str, int | None | object]: if self.max_tool_rounds is _DEFAULT_MAX_TOOL_ROUNDS: return {} diff --git a/firstcoder/app/tui.py b/firstcoder/app/tui.py index fffa277..61198de 100644 --- a/firstcoder/app/tui.py +++ b/firstcoder/app/tui.py @@ -195,12 +195,15 @@ def __init__( chat_runner: ChatRunnerLike | None = None, current_session: CurrentSessionLike | None = None, config: FirstCoderTuiConfig | None = None, + on_shutdown: Callable[[], None] | None = None, ) -> None: super().__init__() self.command_handler = command_handler self.chat_runner = chat_runner self.current_session = current_session self.config = config or FirstCoderTuiConfig() + self._on_shutdown = on_shutdown + self._shutdown_called = False self._chat_busy = False self._chat_worker = None self._chat_turn_token = 0 @@ -269,6 +272,9 @@ def _on_terminal_resized(self) -> None: def on_unmount(self) -> None: self._stop_welcome_particles() self._stop_provider_glow() + if not self._shutdown_called and self._on_shutdown is not None: + self._shutdown_called = True + self._on_shutdown() async def _submit_composer(self) -> None: input_widget = self.query_one("#input", TextArea) diff --git a/firstcoder/config/settings.py b/firstcoder/config/settings.py index 0a74b48..3291b11 100644 --- a/firstcoder/config/settings.py +++ b/firstcoder/config/settings.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from copy import deepcopy from dataclasses import dataclass from pathlib import Path from typing import Any @@ -92,6 +93,20 @@ def get_config_value(self, name: str, *, default: str | None = None) -> str | No return value return default + def mcp_config(self) -> dict[str, Any]: + """返回按服务器名合并的原始 MCP 配置,项目配置完整覆盖同名全局配置。""" + + merged: dict[str, Any] = {} + for config in (self.global_config, self.project_config): + if not config or "mcp" not in config: + continue + raw_mcp = config["mcp"] + if not isinstance(raw_mcp, dict): + raise ValueError("[mcp] 配置必须是表") + for name, server_config in raw_mcp.items(): + merged[name] = deepcopy(server_config) + return merged + @property def loaded_config_paths(self) -> list[Path]: """已经存在并被加载的配置文件路径。""" diff --git a/firstcoder/mcp/__init__.py b/firstcoder/mcp/__init__.py new file mode 100644 index 0000000..57083c2 --- /dev/null +++ b/firstcoder/mcp/__init__.py @@ -0,0 +1,20 @@ +"""MCP 配置和运行时模型的公开入口。""" + +from firstcoder.mcp.config import load_mcp_configs, resolve_environment_placeholders +from firstcoder.mcp.models import ( + McpConfigError, + McpLocalServerConfig, + McpRemoteServerConfig, + McpServerStatus, + McpToolDescription, +) + +__all__ = [ + "McpConfigError", + "McpLocalServerConfig", + "McpRemoteServerConfig", + "McpServerStatus", + "McpToolDescription", + "load_mcp_configs", + "resolve_environment_placeholders", +] diff --git a/firstcoder/mcp/adapter.py b/firstcoder/mcp/adapter.py new file mode 100644 index 0000000..e95b010 --- /dev/null +++ b/firstcoder/mcp/adapter.py @@ -0,0 +1,134 @@ +"""把发现到的 MCP 工具转换为 FirstCoder 同步工具。""" + +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from typing import Any, Protocol + +from firstcoder.mcp.models import McpToolDescription +from firstcoder.permissions.types import PermissionAction +from firstcoder.providers.types import ToolDefinition +from firstcoder.tools.types import Tool, ToolPermissionSpec, ToolResult, make_error_result + +_SAFE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + + +class McpToolCaller(Protocol): + """适配器所需的同步 MCP 调用入口。""" + + def call_tool(self, server: str, tool: str, arguments: dict[str, object]) -> object: ... + + +def adapt_mcp_tool( + manager: McpToolCaller, + server: str, + discovered_tool: McpToolDescription, + *, + existing_names: set[str] | None = None, +) -> Tool: + """把一个发现到的 MCP tool 转换成经过既有权限链路执行的 ``Tool``。""" + + tool_name = discovered_tool.name + if not _SAFE_NAME.fullmatch(server) or not _SAFE_NAME.fullmatch(tool_name): + raise ValueError("MCP server/tool 名称不合法") + name = f"mcp__{server}__{tool_name}" + if name in (existing_names or set()): + raise ValueError(f"MCP 工具名称冲突:{name}") + try: + parameters = _tool_parameters(discovered_tool.input_schema) + except ValueError: + parameters = {"type": "object", "properties": {}} + schema_error = True + else: + schema_error = False + + def execute(**arguments: Any) -> ToolResult: + if schema_error: + return make_error_result(name, "MCP 工具参数 schema 无效。") + try: + result = manager.call_tool(server, tool_name, dict(arguments)) + except Exception: + return make_error_result(name, "MCP 工具调用失败。") + return _tool_result(name, server, tool_name, result) + + return Tool( + definition=ToolDefinition( + name=name, + description=discovered_tool.description or f"调用 MCP 工具 {server}/{tool_name}。", + parameters=parameters, + ), + executor=execute, + permission=ToolPermissionSpec( + action=PermissionAction.MCP_TOOL, + target_value=f"{server}/{tool_name}", + allow_auto=False, + reason=f"调用 MCP 工具 {server}/{tool_name}。", + ), + ) + + +def _tool_parameters(input_schema: Mapping[str, object]) -> dict[str, object]: + """验证 MCP 的输入 schema,并复制为 provider 需要的对象 schema。""" + + schema = dict(input_schema) + if not schema: + return {"type": "object", "properties": {}} + if schema.get("type") != "object": + raise ValueError("MCP input schema 必须是 object") + properties = schema.get("properties", {}) + required = schema.get("required", []) + if not isinstance(properties, Mapping) or not all(isinstance(key, str) for key in properties): + raise ValueError("MCP input schema properties 无效") + if not isinstance(required, list) or not all(isinstance(key, str) and key in properties for key in required): + raise ValueError("MCP input schema required 无效") + return schema + + +def _tool_result(name: str, server: str, tool: str, result: object) -> ToolResult: + """将 MCP 结果转换为模型可读文本,并保留结构化结果。""" + + content = _field(result, "content", ()) + structured_content = _field(result, "structuredContent", None) + text = _render_content(content) + data: dict[str, Any] = {"mcp": {"server": server, "tool": tool}} + if structured_content is not None: + data["mcp"]["structured_content"] = structured_content + if bool(_field(result, "isError", False)): + message = text or "MCP 工具返回错误。" + return ToolResult(name=name, ok=False, content=message, data=data, error=message) + return ToolResult(name=name, ok=True, content=text or "MCP 工具调用完成。", data=data) + + +def _field(value: object, name: str, default: object) -> object: + if isinstance(value, Mapping): + return value.get(name, default) + return getattr(value, name, default) + + +def _render_content(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, (list, tuple)): + return _safe_text(content) + rendered: list[str] = [] + for item in content: + if _field(item, "type", "") == "text": + text = _field(item, "text", "") + if isinstance(text, str) and text: + rendered.append(text) + else: + rendered.append(_safe_text(item)) + return "\n".join(part for part in rendered if part) + + +def _safe_text(value: object) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, default=str, sort_keys=True) + except (TypeError, ValueError): + return str(value) diff --git a/firstcoder/mcp/config.py b/firstcoder/mcp/config.py new file mode 100644 index 0000000..8270e64 --- /dev/null +++ b/firstcoder/mcp/config.py @@ -0,0 +1,138 @@ +"""MCP TOML 配置提取与校验。""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from firstcoder.mcp.models import McpConfigError, McpLocalServerConfig, McpRemoteServerConfig + +if TYPE_CHECKING: + from firstcoder.config.settings import AppConfig + + +_ENV_PLACEHOLDER = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}") +_ALLOWED_TOOL_NAME = re.compile(r"[A-Za-z0-9_*-]+") + + +def load_mcp_configs(app_config: AppConfig) -> tuple[McpLocalServerConfig | McpRemoteServerConfig, ...]: + """读取并校验合并后的 MCP 服务器期望配置。""" + + raw_servers = app_config.mcp_config() + configs: list[McpLocalServerConfig | McpRemoteServerConfig] = [] + for name, raw_server in raw_servers.items(): + configs.append(_parse_server(name, raw_server)) + return tuple(configs) + + +def resolve_environment_placeholders(value: Any, env: Mapping[str, str]) -> Any: + """递归替换配置中的环境变量占位符,缺失时不泄露任何变量值。""" + + if isinstance(value, str): + return _ENV_PLACEHOLDER.sub(lambda match: _environment_value(match.group(1), env), value) + if isinstance(value, list): + return [resolve_environment_placeholders(item, env) for item in value] + if isinstance(value, tuple): + return tuple(resolve_environment_placeholders(item, env) for item in value) + if isinstance(value, Mapping): + return { + key: resolve_environment_placeholders(item, env) + for key, item in value.items() + } + return value + + +def _environment_value(name: str, env: Mapping[str, str]) -> str: + value = env.get(name) + if value is None or value == "": + raise McpConfigError(f"缺少环境变量:{name}") + return value + + +def _parse_server(name: object, raw_server: object) -> McpLocalServerConfig | McpRemoteServerConfig: + if not isinstance(name, str) or not name: + raise McpConfigError("MCP 服务器名称必须是非空字符串") + if not isinstance(raw_server, Mapping): + raise McpConfigError(f"MCP 服务器 {name} 必须是配置表") + + server_type = raw_server.get("type") + if server_type not in {"local", "remote"}: + raise McpConfigError(f"MCP 服务器 {name} 的 type 必须是 local 或 remote") + enabled = _bool(raw_server.get("enabled", True), name, "enabled") + timeout_ms = _timeout(raw_server.get("timeout_ms", 5000), name) + allowed_tools = _allowed_tools(raw_server.get("allowed_tools"), name) + + if server_type == "local": + _reject_field(raw_server, name, "url") + _reject_field(raw_server, name, "headers") + return McpLocalServerConfig( + name=name, + command=_command(raw_server.get("command"), name), + env=_string_mapping(raw_server.get("env", {}), name, "env"), + enabled=enabled, + timeout_ms=timeout_ms, + allowed_tools=allowed_tools, + ) + + _reject_field(raw_server, name, "command") + _reject_field(raw_server, name, "env") + return McpRemoteServerConfig( + name=name, + url=_url(raw_server.get("url"), name), + headers=_string_mapping(raw_server.get("headers", {}), name, "headers"), + enabled=enabled, + timeout_ms=timeout_ms, + allowed_tools=allowed_tools, + ) + + +def _reject_field(server: Mapping[str, object], name: str, field: str) -> None: + if field in server: + raise McpConfigError(f"MCP 服务器 {name} 不能同时配置 {field}") + + +def _command(value: object, name: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): + raise McpConfigError(f"MCP 服务器 {name} 的 command 必须是非空字符串列表") + return tuple(value) + + +def _url(value: object, name: str) -> str: + if not isinstance(value, str): + raise McpConfigError(f"MCP 服务器 {name} 的 url 必须是 HTTP URL") + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise McpConfigError(f"MCP 服务器 {name} 的 url 必须是 HTTP URL") + return value + + +def _string_mapping(value: object, name: str, field: str) -> dict[str, str]: + if not isinstance(value, Mapping) or any( + not isinstance(key, str) or not isinstance(item, str) for key, item in value.items() + ): + raise McpConfigError(f"MCP 服务器 {name} 的 {field} 必须是字符串映射") + return dict(value) + + +def _bool(value: object, name: str, field: str) -> bool: + if not isinstance(value, bool): + raise McpConfigError(f"MCP 服务器 {name} 的 {field} 必须是布尔值") + return value + + +def _timeout(value: object, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise McpConfigError(f"MCP 服务器 {name} 的 timeout_ms 必须是正整数") + return value + + +def _allowed_tools(value: object, name: str) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, list) or any( + not isinstance(item, str) or not _ALLOWED_TOOL_NAME.fullmatch(item) for item in value + ): + raise McpConfigError(f"MCP 服务器 {name} 的 allowed_tools 必须是有效工具名列表") + return tuple(value) diff --git a/firstcoder/mcp/manager.py b/firstcoder/mcp/manager.py new file mode 100644 index 0000000..73db737 --- /dev/null +++ b/firstcoder/mcp/manager.py @@ -0,0 +1,191 @@ +"""同步 FirstCoder 与异步 MCP SDK 之间的连接协调器。""" + +from __future__ import annotations + +import asyncio +import fnmatch +import os +import threading +from concurrent.futures import Future, TimeoutError as FutureTimeoutError +from dataclasses import replace +from typing import Coroutine, Literal, Mapping + +from firstcoder.mcp.config import resolve_environment_placeholders +from firstcoder.mcp.models import McpConfigError, McpLocalServerConfig, McpRemoteServerConfig, McpServerStatus, McpToolDescription +from firstcoder.mcp.transport import McpTransport, McpTransportFactory, SdkMcpTransportFactory + + +McpServerConfig = McpLocalServerConfig | McpRemoteServerConfig + + +class McpManager: + """在守护线程中维护 MCP 连接,并提供同步调用入口。""" + + def __init__( + self, + configs: tuple[McpServerConfig, ...], + transport_factory: McpTransportFactory | None = None, + environment: Mapping[str, str] | None = None, + ) -> None: + self._configs = {config.name: config for config in configs} + self._factory = transport_factory or SdkMcpTransportFactory() + self._environment = os.environ if environment is None else environment + self._lock = threading.RLock() + self._statuses = { + config.name: McpServerStatus(config.name, "disabled" if not config.enabled else "failed") + for config in configs + } + self._transports: dict[str, McpTransport] = {} + self._catalogs: dict[str, tuple[McpToolDescription, ...]] = {} + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, name="firstcoder-mcp", daemon=True) + self._thread.start() + self._closed = False + + def connect_all(self) -> None: + """连接所有启用服务器;任何单个失败都只影响自身状态。""" + + for config in self._configs.values(): + if not config.enabled: + self._set_status(config.name, "disabled") + continue + self._connect_one(config) + + def statuses(self) -> tuple[McpServerStatus, ...]: + """返回所有服务器的安全状态快照。""" + + with self._lock: + return tuple(self._statuses[name] for name in self._configs) + + def doctor(self, name: str) -> McpServerStatus | None: + """返回单个服务器状态;未知名称返回 ``None``。""" + + with self._lock: + return self._statuses.get(name) + + def tools(self) -> tuple[tuple[str, McpToolDescription], ...]: + """返回已连接服务器可用的工具目录。""" + + with self._lock: + return tuple((name, tool) for name in self._configs for tool in self._catalogs.get(name, ())) + + def call_tool(self, server: str, tool: str, arguments: dict[str, object]) -> object: + """同步调用已发现的 MCP 工具。""" + + with self._lock: + config = self._configs.get(server) + transport = self._transports.get(server) + catalog = self._catalogs.get(server, ()) + if config is None or transport is None or not any(item.name == tool for item in catalog): + raise RuntimeError("MCP 工具不可用") + try: + return self._submit(transport.call_tool(tool, arguments), config.timeout_ms) + except FutureTimeoutError as error: + raise RuntimeError("MCP 请求超时") from error + except Exception as error: + raise RuntimeError("MCP 工具调用失败") from error + + def close(self) -> None: + """断开所有连接并停止后台事件循环,可重复调用。""" + + with self._lock: + if self._closed: + return + self._closed = True + transports = tuple(self._transports.items()) + self._transports.clear() + self._catalogs.clear() + for name, transport in transports: + try: + self._submit(transport.close(), 1000) + except Exception: + pass + self._set_status(name, "failed", error="MCP 已断开") + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=1) + + def _connect_one(self, config: McpServerConfig) -> None: + self._set_status(config.name, "connecting") + try: + resolved = self._resolve_config(config) + transport = self._factory.create(resolved) + tools = self._submit(self._initialize(transport), config.timeout_ms) + except McpConfigError: + self._set_status(config.name, "failed", error="MCP 配置无效") + return + except FutureTimeoutError: + self._set_status(config.name, "failed", error="MCP 请求超时") + return + except Exception: + self._set_status(config.name, "failed", error="MCP 连接失败") + return + with self._lock: + self._transports[config.name] = transport + filtered_tools = self._allowed_tools(config, tools) + self._catalogs[config.name] = filtered_tools + self._set_status(config.name, "connected", tool_count=len(filtered_tools)) + + async def _initialize(self, transport: McpTransport) -> tuple[McpToolDescription, ...]: + try: + await transport.connect() + return await transport.list_tools() + except BaseException: + await transport.close() + raise + + def _resolve_config(self, config: McpServerConfig) -> McpServerConfig: + if isinstance(config, McpLocalServerConfig): + return replace( + config, + command=tuple(resolve_environment_placeholders(config.command, self._environment)), + env=resolve_environment_placeholders(config.env, self._environment), + ) + return replace( + config, + url=resolve_environment_placeholders(config.url, self._environment), + headers=resolve_environment_placeholders(config.headers, self._environment), + ) + + def _set_status( + self, + name: str, + state: Literal["disabled", "connecting", "connected", "failed"], + tool_count: int = 0, + error: str | None = None, + ) -> None: + with self._lock: + self._statuses[name] = McpServerStatus(name, state, tool_count, error) + + def _submit(self, coroutine: Coroutine[object, object, object], timeout_ms: int) -> object: + future: Future[object] = asyncio.run_coroutine_threadsafe( + self._with_timeout(coroutine, timeout_ms), self._loop + ) + try: + return future.result(timeout=timeout_ms / 1000 + 0.2) + except (FutureTimeoutError, TimeoutError) as error: + future.cancel() + raise FutureTimeoutError from error + + async def _with_timeout( + self, coroutine: Coroutine[object, object, object], timeout_ms: int + ) -> object: + return await asyncio.wait_for(coroutine, timeout=timeout_ms / 1000) + + @staticmethod + def _allowed_tools( + config: McpServerConfig, tools: tuple[McpToolDescription, ...] + ) -> tuple[McpToolDescription, ...]: + if config.allowed_tools is None: + return tools + return tuple( + tool + for tool in tools + if any(fnmatch.fnmatchcase(tool.name, pattern) for pattern in config.allowed_tools) + ) + + def _run_loop(self) -> None: + asyncio.set_event_loop(self._loop) + try: + self._loop.run_forever() + finally: + self._loop.close() diff --git a/firstcoder/mcp/models.py b/firstcoder/mcp/models.py new file mode 100644 index 0000000..769ae13 --- /dev/null +++ b/firstcoder/mcp/models.py @@ -0,0 +1,63 @@ +"""MCP 配置与运行状态的数据模型。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Literal, Mapping + + +class McpConfigError(ValueError): + """表示不会暴露敏感配置值的 MCP 配置错误。""" + + +@dataclass(frozen=True, slots=True) +class McpLocalServerConfig: + """本地 stdio MCP 服务器的期望配置。""" + + name: str + command: tuple[str, ...] + env: Mapping[str, str] = field(default_factory=dict) + enabled: bool = True + timeout_ms: int = 5000 + allowed_tools: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "env", MappingProxyType(dict(self.env))) + + +@dataclass(frozen=True, slots=True) +class McpRemoteServerConfig: + """远程 Streamable HTTP MCP 服务器的期望配置。""" + + name: str + url: str + headers: Mapping[str, str] = field(default_factory=dict) + enabled: bool = True + timeout_ms: int = 5000 + allowed_tools: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "headers", MappingProxyType(dict(self.headers))) + + +@dataclass(frozen=True, slots=True) +class McpServerStatus: + """进程内 MCP 服务器连接状态,不是持久化配置。""" + + name: str + state: Literal["disabled", "connecting", "connected", "failed"] + tool_count: int = 0 + error: str | None = None + + +@dataclass(frozen=True, slots=True) +class McpToolDescription: + """MCP 服务端发现的、与传输无关的工具说明。""" + + name: str + description: str | None + input_schema: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "input_schema", MappingProxyType(dict(self.input_schema))) diff --git a/firstcoder/mcp/transport.py b/firstcoder/mcp/transport.py new file mode 100644 index 0000000..3bbab1b --- /dev/null +++ b/firstcoder/mcp/transport.py @@ -0,0 +1,127 @@ +"""基于官方 MCP SDK 的传输适配层。""" + +from __future__ import annotations + +from contextlib import AsyncExitStack +import os +from typing import Any, Mapping, Protocol + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client + +from firstcoder.mcp.models import McpLocalServerConfig, McpRemoteServerConfig, McpToolDescription + + +def _stdio_environment(config_environment: Mapping[str, str]) -> dict[str, str]: + """保留宿主进程环境,并用 MCP 服务端配置覆盖指定变量。""" + + environment = dict(os.environ) + environment.update(config_environment) + return environment + + +class McpTransport(Protocol): + """管理器所需的最小异步 MCP 传输接口。""" + + async def connect(self) -> None: ... + + async def list_tools(self) -> tuple[McpToolDescription, ...]: ... + + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: ... + + async def close(self) -> None: ... + + +class McpTransportFactory(Protocol): + """按服务器配置创建传输,便于在测试中注入替身。""" + + def create(self, config: McpLocalServerConfig | McpRemoteServerConfig) -> McpTransport: ... + + +class SdkMcpTransportFactory: + """创建官方 SDK 支持的 stdio 或 Streamable HTTP 客户端。""" + + def create(self, config: McpLocalServerConfig | McpRemoteServerConfig) -> McpTransport: + if isinstance(config, McpLocalServerConfig): + return _StdioMcpTransport(config) + return _StreamableHttpMcpTransport(config) + + +class _SdkMcpTransport: + """复用 SDK 会话生命周期的内部基类。""" + + def __init__(self) -> None: + self._stack: AsyncExitStack | None = None + self._session: ClientSession | None = None + + async def connect(self) -> None: + stack = AsyncExitStack() + try: + read_stream, write_stream = await self._open_streams(stack) + session = await stack.enter_async_context(ClientSession(read_stream, write_stream)) + await session.initialize() + except BaseException: + await stack.aclose() + raise + self._stack = stack + self._session = session + + async def list_tools(self) -> tuple[McpToolDescription, ...]: + session = self._require_session() + result = await session.list_tools() + return tuple( + McpToolDescription( + name=tool.name, + description=tool.description, + input_schema=dict(tool.inputSchema), + ) + for tool in result.tools + ) + + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: + return await self._require_session().call_tool(name, arguments) + + async def close(self) -> None: + if self._stack is not None: + stack, self._stack = self._stack, None + self._session = None + await stack.aclose() + + def _require_session(self) -> ClientSession: + if self._session is None: + raise RuntimeError("MCP 传输尚未连接") + return self._session + + async def _open_streams(self, stack: AsyncExitStack) -> tuple[Any, Any]: + raise NotImplementedError + + +class _StdioMcpTransport(_SdkMcpTransport): + """本地 stdio 服务端传输;SDK 直接接收 argv,不经过 shell。""" + + def __init__(self, config: McpLocalServerConfig) -> None: + super().__init__() + self._config = config + + async def _open_streams(self, stack: AsyncExitStack) -> tuple[Any, Any]: + parameters = StdioServerParameters( + command=self._config.command[0], + args=list(self._config.command[1:]), + env=_stdio_environment(self._config.env), + ) + return await stack.enter_async_context(stdio_client(parameters)) + + +class _StreamableHttpMcpTransport(_SdkMcpTransport): + """远程 Streamable HTTP 服务端传输。""" + + def __init__(self, config: McpRemoteServerConfig) -> None: + super().__init__() + self._config = config + + async def _open_streams(self, stack: AsyncExitStack) -> tuple[Any, Any]: + read_stream, write_stream, _ = await stack.enter_async_context( + streamablehttp_client(self._config.url, headers=dict(self._config.headers)) + ) + return read_stream, write_stream diff --git a/firstcoder/permissions/grants.py b/firstcoder/permissions/grants.py index c390f9b..49c95f7 100644 --- a/firstcoder/permissions/grants.py +++ b/firstcoder/permissions/grants.py @@ -110,6 +110,8 @@ def _grant_matches(grant: PermissionGrant, request: PermissionRequest) -> bool: return _host_from_target(request.target) == grant.scope_value.lower() if grant.scope_type == PermissionScopeType.ENV_KEY: return request.target.upper() == grant.scope_value.upper() + if grant.scope_type == PermissionScopeType.MCP_TOOL: + return request.action == PermissionAction.MCP_TOOL and request.target == grant.scope_value return False diff --git a/firstcoder/permissions/manager.py b/firstcoder/permissions/manager.py index 96a6da3..82f4600 100644 --- a/firstcoder/permissions/manager.py +++ b/firstcoder/permissions/manager.py @@ -202,6 +202,8 @@ def default_scope_for_request(request: PermissionRequest, *, project_root: Path return _PermissionScope(scope_type=PermissionScopeType.ENV_KEY, scope_value=request.target.upper()) if request.action == PermissionAction.GIT_OPERATION: return _PermissionScope(scope_type=PermissionScopeType.COMMAND_PREFIX, scope_value=_git_command_scope(request.target)) + if request.action == PermissionAction.MCP_TOOL: + return _PermissionScope(scope_type=PermissionScopeType.MCP_TOOL, scope_value=request.target) return _PermissionScope(scope_type=PermissionScopeType.COMMAND_PREFIX, scope_value=request.target.strip()) diff --git a/firstcoder/permissions/policy.py b/firstcoder/permissions/policy.py index deac7d6..36d404d 100644 --- a/firstcoder/permissions/policy.py +++ b/firstcoder/permissions/policy.py @@ -77,6 +77,8 @@ def decide(self, request: PermissionRequest, *, mode: PermissionMode) -> Permiss return self._decide_shell(request, mode=mode) if request.action == PermissionAction.NETWORK_REQUEST: return self._ask("网络请求需要用户确认。") + if request.action == PermissionAction.MCP_TOOL: + return self._ask("MCP 工具调用需要用户确认。") return self._ask("未知权限请求需要用户确认。") def _decide_path(self, request: PermissionRequest, *, mode: PermissionMode) -> PermissionDecision: diff --git a/firstcoder/permissions/types.py b/firstcoder/permissions/types.py index 79edd36..fd5b1c5 100644 --- a/firstcoder/permissions/types.py +++ b/firstcoder/permissions/types.py @@ -22,6 +22,7 @@ class PermissionAction(StrEnum): NETWORK_REQUEST = "network_request" GIT_OPERATION = "git_operation" READ_ENV = "read_env" + MCP_TOOL = "mcp_tool" class PermissionMode(StrEnum): @@ -57,6 +58,7 @@ class PermissionScopeType(StrEnum): COMMAND_PREFIX = "command_prefix" HOST = "host" ENV_KEY = "env_key" + MCP_TOOL = "mcp_tool" class PermissionConfirmationChoice(StrEnum): diff --git a/firstcoder/session/fork.py b/firstcoder/session/fork.py index 959440d..bf8ffbf 100644 --- a/firstcoder/session/fork.py +++ b/firstcoder/session/fork.py @@ -5,6 +5,7 @@ import shutil from dataclasses import dataclass from pathlib import Path +from collections.abc import Callable from firstcoder.agent.prompt_inputs import read_agents_md from firstcoder.agent.session import AgentSession, create_project_permission_manager @@ -29,6 +30,7 @@ class ForkSessionService: project_root: str | Path data_root: str | Path | None = None tools: list[Tool] | None = None + tools_provider: Callable[[], list[Tool]] | None = None sandbox_access: SandboxAccess | None = None catalog: SessionCatalog | None = None @@ -59,7 +61,7 @@ def fork(self, source_session_id: str, *, title: str | None = None) -> ResumeRes session_id=forked_session_id, agents_md=read_agents_md(self.project_root), skill_catalog=discover_all_skills(self.project_root), - tools=self.tools, + tools=self._tools(), permission_manager=create_project_permission_manager( self.project_root, grants=FilePermissionGrantStore(data_root / "permissions.json"), @@ -69,6 +71,9 @@ def fork(self, source_session_id: str, *, title: str | None = None) -> ResumeRes session.restore_pending_permission_execution() return ResumeResult(session=session, record=catalog.get_session(forked_session_id)) + def _tools(self) -> list[Tool] | None: + return self.tools_provider() if self.tools_provider is not None else self.tools + def _copy_archives(self, source_session_id: str, forked_session_id: str) -> None: source = self.store.root / "archives" / source_session_id if not source.exists(): diff --git a/firstcoder/session/new.py b/firstcoder/session/new.py index c0a3553..15b24e4 100644 --- a/firstcoder/session/new.py +++ b/firstcoder/session/new.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from pathlib import Path +from collections.abc import Callable from firstcoder.agent.prompt_inputs import read_agents_md from firstcoder.agent.session import AgentSession, create_project_permission_manager @@ -26,6 +27,7 @@ class NewSessionService: project_root: str | Path data_root: str | Path | None = None tools: list[Tool] | None = None + tools_provider: Callable[[], list[Tool]] | None = None sandbox_access: SandboxAccess | None = None def create(self, *, title: str | None = None) -> ResumeResult: @@ -36,7 +38,7 @@ def create(self, *, title: str | None = None) -> ResumeResult: session_id=session_id, agents_md=read_agents_md(self.project_root), skill_catalog=discover_all_skills(self.project_root), - tools=self.tools, + tools=self._tools(), permission_manager=create_project_permission_manager( self.project_root, grants=FilePermissionGrantStore(data_root / "permissions.json"), @@ -47,3 +49,6 @@ def create(self, *, title: str | None = None) -> ResumeResult: SessionEventWriter(store=self.store, session_id=session_id).append_session_metadata_updated(title=title) record = SessionCatalog(self.store.root).get_session(session_id) return ResumeResult(session=session, record=record) + + def _tools(self) -> list[Tool] | None: + return self.tools_provider() if self.tools_provider is not None else self.tools diff --git a/firstcoder/session/resume.py b/firstcoder/session/resume.py index 7018e69..720a215 100644 --- a/firstcoder/session/resume.py +++ b/firstcoder/session/resume.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from pathlib import Path +from collections.abc import Callable from firstcoder.agent.prompt_inputs import read_agents_md from firstcoder.agent.session import AgentSession, create_project_permission_manager @@ -29,6 +30,7 @@ class ResumeService: project_root: str | Path data_root: str | Path | None = None tools: list[Tool] | None = None + tools_provider: Callable[[], list[Tool]] | None = None sandbox_access: SandboxAccess | None = None catalog: SessionCatalog | None = None @@ -46,7 +48,7 @@ def resume(self, session_id: str) -> ResumeResult: session_id=session_id, agents_md=read_agents_md(self.project_root), skill_catalog=discover_all_skills(self.project_root), - tools=self.tools, + tools=self._tools(), permission_manager=create_project_permission_manager( self.project_root, grants=FilePermissionGrantStore(data_root / "permissions.json"), @@ -55,3 +57,6 @@ def resume(self, session_id: str) -> ResumeResult: ) session.restore_pending_permission_execution() return ResumeResult(session=session, record=record) + + def _tools(self) -> list[Tool] | None: + return self.tools_provider() if self.tools_provider is not None else self.tools diff --git a/pyproject.toml b/pyproject.toml index 9e22e42..8a4240c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "firstcoder" -version = "0.1.2" +version = "0.1.3" description = "A local Python coding agent with a Textual TUI." readme = "README.md" requires-python = ">=3.11" @@ -14,6 +14,7 @@ dependencies = [ "openai", "anthropic", "anyio", + "mcp>=1.28.1", "python-dotenv", "pydantic", ] diff --git a/tests/fixtures/mcp_echo_server.py b/tests/fixtures/mcp_echo_server.py new file mode 100644 index 0000000..fb812e3 --- /dev/null +++ b/tests/fixtures/mcp_echo_server.py @@ -0,0 +1,17 @@ +"""用于 MCP 集成测试的最小 stdio echo 服务端。""" + +from mcp.server.fastmcp import FastMCP + + +server = FastMCP("firstcoder-integration-echo") + + +@server.tool() +def echo(message: str) -> str: + """原样返回调用参数。""" + + return message + + +if __name__ == "__main__": + server.run(transport="stdio") diff --git a/tests/test_agent_prompt_inputs.py b/tests/test_agent_prompt_inputs.py index cffc9ca..69e7072 100644 --- a/tests/test_agent_prompt_inputs.py +++ b/tests/test_agent_prompt_inputs.py @@ -42,3 +42,7 @@ def test_build_system_prompt_inputs_uses_permission_policy_without_tool_schema() assert '"model": "fake-model"' in content assert '"write": "allow"' in content assert inputs.permission_policy["shell"] == DEFAULT_PERMISSION_POLICY["shell"] + + +def test_default_permission_policy_describes_mcp_tool_confirmation() -> None: + assert DEFAULT_PERMISSION_POLICY["mcp_tools"] == "confirm" diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index ce2c691..dd008c2 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -11,6 +11,9 @@ from firstcoder.providers.base import ChatProvider from firstcoder.providers.types import ChatRequest, ChatResponse, ProviderCapabilities, ToolCall from firstcoder.tools.write import create_write_tool +from firstcoder.tools.types import Tool +from firstcoder.providers.types import ToolDefinition +from firstcoder.mcp.models import McpServerStatus, McpToolDescription @dataclass @@ -32,6 +35,94 @@ def complete(self, request: ChatRequest) -> ChatResponse: return self.responses.pop(0) +class FakeMcpManager: + def __init__(self, tools=(), statuses=()) -> None: + self.tools_value = tools + self.statuses_value = statuses + self.connect_calls = 0 + self.close_calls = 0 + + def connect_all(self) -> None: + self.connect_calls += 1 + + def tools(self): + return self.tools_value + + def statuses(self): + return self.statuses_value + + def doctor(self, name: str): + return next((status for status in self.statuses_value if status.name == name), None) + + def call_tool(self, server: str, tool: str, arguments: dict[str, object]): + return {"content": [{"type": "text", "text": "ok"}]} + + def close(self) -> None: + self.close_calls += 1 + + +def test_factory_connects_mcp_once_and_merges_discovered_tools(tmp_path: Path) -> None: + manager = FakeMcpManager( + tools=(("demo", McpToolDescription("ping", "Ping", {"type": "object", "properties": {}})),), + statuses=(McpServerStatus("demo", "connected", tool_count=1),), + ) + app = create_firstcoder_app( + project_root=tmp_path, + provider=FakeProvider([]), + session_id="sess_test", + mcp_manager_factory=lambda configs: manager, + ) + + assert manager.connect_calls == 1 + assert "write" in [tool.name for tool in app.current_session.session.tool_registry.tools()] + assert "mcp__demo__ping" in [tool.name for tool in app.current_session.session.tool_registry.tools()] + assert "/mcp list" in app.command_handler.handle("/help").output + + +def test_factory_keeps_builtin_tools_when_mcp_connection_fails(tmp_path: Path) -> None: + manager = FakeMcpManager(statuses=(McpServerStatus("demo", "failed", error="safe failure"),)) + app = create_firstcoder_app( + project_root=tmp_path, + provider=FakeProvider([]), + session_id="sess_test", + mcp_manager_factory=lambda configs: manager, + ) + + assert "write" in [tool.name for tool in app.current_session.session.tool_registry.tools()] + assert "mcp__demo__ping" not in [tool.name for tool in app.current_session.session.tool_registry.tools()] + + +def test_factory_custom_tools_mode_does_not_append_mcp_tools(tmp_path: Path) -> None: + manager = FakeMcpManager( + tools=(("demo", McpToolDescription("ping", "Ping", {"type": "object", "properties": {}})),), + ) + app = create_firstcoder_app( + project_root=tmp_path, + provider=FakeProvider([]), + session_id="sess_test", + tools=[], + mcp_manager_factory=lambda configs: manager, + ) + + assert "mcp__demo__ping" not in app.current_session.session.tool_registry.names() + + +def test_app_unmount_closes_mcp_manager_once(tmp_path: Path) -> None: + manager = FakeMcpManager() + app = create_firstcoder_app( + project_root=tmp_path, + provider=FakeProvider([]), + session_id="sess_test", + tools=[], + mcp_manager_factory=lambda configs: manager, + ) + + app.on_unmount() + app.on_unmount() + + assert manager.close_calls == 1 + + def test_create_firstcoder_app_wires_session_commands_context_and_chat(tmp_path: Path) -> None: (tmp_path / "AGENTS.md").write_text("项目规则", encoding="utf-8") provider = FakeProvider([ChatResponse(provider="fake", model="fake-model", content="收到")]) diff --git a/tests/test_app_mcp_commands.py b/tests/test_app_mcp_commands.py new file mode 100644 index 0000000..6ec330b --- /dev/null +++ b/tests/test_app_mcp_commands.py @@ -0,0 +1,41 @@ +from firstcoder.app.mcp_commands import McpCommandHandler +from firstcoder.mcp.models import McpServerStatus + + +class FakeMcpManager: + def __init__(self) -> None: + self.statuses_value = ( + McpServerStatus("lark", "connected", tool_count=2), + McpServerStatus("broken", "failed", error="safe failure"), + ) + + def statuses(self): + return self.statuses_value + + def doctor(self, name: str): + return next((status for status in self.statuses_value if status.name == name), None) + + +def test_mcp_list_renders_safe_statuses_and_tool_counts() -> None: + result = McpCommandHandler(FakeMcpManager()).handle("/mcp list") + + assert result.handled is True + assert "MCP servers:" in result.output + assert "lark: connected (2 tools)" in result.output + assert "broken: failed (0 tools) - error" in result.output + + +def test_mcp_doctor_renders_one_server() -> None: + result = McpCommandHandler(FakeMcpManager()).handle("/mcp doctor lark") + + assert result.handled is True + assert result.output == "MCP lark: connected (2 tools)" + + +def test_mcp_command_reports_usage_and_unknown_server() -> None: + handler = McpCommandHandler(FakeMcpManager()) + + assert handler.handle("/mcp").output == "Usage: /mcp list | /mcp doctor " + assert handler.handle("/mcp doctor").output == "Usage: /mcp doctor " + assert handler.handle("/mcp doctor missing").output == "Unknown MCP server: missing" + assert handler.handle("/mcp nonsense").output == "Usage: /mcp list | /mcp doctor " diff --git a/tests/test_app_session_commands.py b/tests/test_app_session_commands.py index 66a759f..70a0b5b 100644 --- a/tests/test_app_session_commands.py +++ b/tests/test_app_session_commands.py @@ -13,6 +13,8 @@ from firstcoder.session.new import NewSessionService from firstcoder.session.resume import ResumeService from firstcoder.session.share import SessionShareService +from firstcoder.tools.types import Tool, ToolResult +from firstcoder.providers.types import ToolDefinition class CurrentSession: @@ -20,6 +22,35 @@ def __init__(self, session_id: str) -> None: self.session_id = session_id +def _tool(name: str) -> Tool: + return Tool(ToolDefinition(name=name, description=name, parameters={"type": "object"}), lambda **_: ToolResult(name, True, "ok")) + + +def test_new_fork_and_resume_use_current_tool_provider(tmp_path: Path) -> None: + store = JsonlSessionStore(tmp_path) + current_tools = [_tool("mcp__demo__one")] + provider = lambda: list(current_tools) + initial = AgentSession.create(store=store, session_id="sess_one", agents_md="", tools=provider()) + AgentSession.create(store=store, session_id="sess_two", agents_md="", tools=provider()) + state = CurrentSessionState(initial) + handler = SessionCommandHandler( + catalog=SessionCatalog(tmp_path), + current_session=state.session, + new_service=NewSessionService(store=store, project_root=tmp_path, tools_provider=provider), + fork_service=ForkSessionService(store=store, project_root=tmp_path, tools_provider=provider), + resume_service=ResumeService(store=store, project_root=tmp_path, tools_provider=provider), + on_resume=state.set_session, + ) + + current_tools[:] = [_tool("mcp__demo__two")] + handler.handle("/new") + assert "mcp__demo__two" in state.session.tool_registry.names() + handler.handle("/fork") + assert "mcp__demo__two" in state.session.tool_registry.names() + handler.handle("/resume sess_two") + assert "mcp__demo__two" in state.session.tool_registry.names() + + def _make_session(store: JsonlSessionStore, session_id: str, *, title: str = "demo") -> None: writer = SessionEventWriter(store=store, session_id=session_id) writer.append_session_created(title=title) diff --git a/tests/test_config.py b/tests/test_config.py index 567d1d3..b90e116 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -192,6 +192,20 @@ def test_default_global_config_path_respects_xdg_config_home(tmp_path, monkeypat assert default_global_config_path() == tmp_path / "firstcoder" / "config.toml" +def test_mcp_config_merges_servers_without_using_provider_accessors(): + config = AppConfig( + provider_name="openai", + env={}, + global_config={"mcp": {"global": {"type": "local", "command": ["global"]}}}, + project_config={"mcp": {"project": {"type": "remote", "url": "https://example.test/mcp"}}}, + ) + + assert config.mcp_config() == { + "global": {"type": "local", "command": ["global"]}, + "project": {"type": "remote", "url": "https://example.test/mcp"}, + } + + def test_openai_compatible_presets_have_constructable_metadata(): expected = { "openai", diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py new file mode 100644 index 0000000..0f65ac2 --- /dev/null +++ b/tests/test_mcp_adapter.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from firstcoder.mcp.adapter import adapt_mcp_tool +from firstcoder.mcp.models import McpToolDescription +from firstcoder.permissions.types import PermissionAction +from firstcoder.tools.permission_registry import permission_request_for_tool + + +@dataclass +class FakeManager: + result: object = None + error: Exception | None = None + + def __post_init__(self) -> None: + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + def call_tool(self, server: str, tool: str, arguments: dict[str, object]) -> object: + self.calls.append((server, tool, arguments)) + if self.error is not None: + raise self.error + return self.result + + +def test_adapt_mcp_tool_preserves_description_and_object_schema() -> None: + manager = FakeManager() + discovered = McpToolDescription( + "calendar_list", + "列出指定时间段的日程。", + { + "type": "object", + "properties": { + "start": {"type": "string", "description": "开始时间"}, + "limit": {"type": "integer", "description": "最多返回数量"}, + }, + "required": ["start"], + }, + ) + + tool = adapt_mcp_tool(manager, "lark", discovered) + + assert tool.name == "mcp__lark__calendar_list" + assert tool.definition.description == "列出指定时间段的日程。" + assert tool.definition.parameters == { + "type": "object", + "properties": { + "start": {"type": "string", "description": "开始时间"}, + "limit": {"type": "integer", "description": "最多返回数量"}, + }, + "required": ["start"], + } + + +def test_adapt_mcp_tool_keeps_optional_properties_out_of_required() -> None: + tool = adapt_mcp_tool( + FakeManager(), + "docs", + McpToolDescription( + "read", + None, + { + "type": "object", + "properties": {"id": {"type": "string"}, "revision": {"type": "integer"}}, + "required": ["id"], + }, + ), + ) + + assert tool.definition.parameters["required"] == ["id"] + assert "revision" in tool.definition.parameters["properties"] + + +@pytest.mark.parametrize("server,tool_name", [("bad.name", "echo"), ("good", "bad name")]) +def test_adapt_mcp_tool_rejects_unsafe_server_or_tool_names(server: str, tool_name: str) -> None: + with pytest.raises(ValueError, match="MCP server/tool 名称不合法"): + adapt_mcp_tool(FakeManager(), server, McpToolDescription(tool_name, None)) + + +def test_adapt_mcp_tool_rejects_existing_name_collision() -> None: + with pytest.raises(ValueError, match="MCP 工具名称冲突"): + adapt_mcp_tool( + FakeManager(), + "lark", + McpToolDescription("calendar_list", None), + existing_names={"mcp__lark__calendar_list"}, + ) + + +def test_adapt_mcp_tool_returns_error_for_invalid_schema_without_calling_manager() -> None: + manager = FakeManager() + tool = adapt_mcp_tool(manager, "lark", McpToolDescription("bad_schema", None, {"type": "array"})) + + result = tool.executor() + + assert result.ok is False + assert result.error == "MCP 工具参数 schema 无效。" + assert manager.calls == [] + + +def test_adapt_mcp_tool_renders_text_and_retains_structured_metadata() -> None: + manager = FakeManager( + result={ + "content": [{"type": "text", "text": "已找到 2 条日程"}], + "structuredContent": {"events": [{"id": "evt_1"}, {"id": "evt_2"}]}, + } + ) + tool = adapt_mcp_tool(manager, "lark", McpToolDescription("calendar_list", None)) + + result = tool.executor(limit=2) + + assert result.ok is True + assert result.content == "已找到 2 条日程" + assert result.data["mcp"] == { + "server": "lark", + "tool": "calendar_list", + "structured_content": {"events": [{"id": "evt_1"}, {"id": "evt_2"}]}, + } + assert manager.calls == [("lark", "calendar_list", {"limit": 2})] + + +def test_adapt_mcp_tool_converts_call_failure_to_safe_error() -> None: + tool = adapt_mcp_tool( + FakeManager(error=RuntimeError("Bearer secret-value")), "lark", McpToolDescription("calendar_list", None) + ) + + result = tool.executor() + + assert result.ok is False + assert result.error == "MCP 工具调用失败。" + assert "secret-value" not in result.content + + +def test_adapt_mcp_tool_declares_precise_mcp_permission() -> None: + tool = adapt_mcp_tool(FakeManager(), "lark", McpToolDescription("calendar_list", None)) + + assert tool.permission is not None + assert tool.permission.action == PermissionAction.MCP_TOOL + assert tool.permission.target_value == "lark/calendar_list" + assert tool.permission.allow_auto is False + + request = permission_request_for_tool(tool, {"limit": 2}) + + assert request.action == PermissionAction.MCP_TOOL + assert request.target == "lark/calendar_list" diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py new file mode 100644 index 0000000..b5a12a3 --- /dev/null +++ b/tests/test_mcp_config.py @@ -0,0 +1,153 @@ +"""MCP 配置解析测试。""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from firstcoder.config import AppConfig +from firstcoder.mcp.config import load_mcp_configs, resolve_environment_placeholders +from firstcoder.mcp.models import McpConfigError, McpLocalServerConfig, McpRemoteServerConfig + + +def test_load_mcp_configs_parses_local_server_with_defaults(): + config = AppConfig( + provider_name="openai", + env={}, + project_config={ + "mcp": {"lark": {"type": "local", "command": ["lark-mcp", "serve"]}} + }, + ) + + servers = load_mcp_configs(config) + + assert servers == ( + McpLocalServerConfig( + name="lark", + command=("lark-mcp", "serve"), + env={}, + allowed_tools=None, + ), + ) + assert servers[0].enabled is True + assert servers[0].timeout_ms == 5000 + with pytest.raises(FrozenInstanceError): + servers[0].enabled = False + + +def test_load_mcp_configs_parses_remote_server(): + config = AppConfig( + provider_name="openai", + env={}, + project_config={ + "mcp": { + "github": { + "type": "remote", + "url": "https://example.test/mcp", + "headers": {"Accept": "application/json"}, + "enabled": False, + "timeout_ms": 8000, + "allowed_tools": ["issues_*", "pull_request_read"], + } + } + }, + ) + + servers = load_mcp_configs(config) + + assert servers == ( + McpRemoteServerConfig( + name="github", + url="https://example.test/mcp", + headers={"Accept": "application/json"}, + enabled=False, + timeout_ms=8000, + allowed_tools=("issues_*", "pull_request_read"), + ), + ) + + +@pytest.mark.parametrize( + "server", + [ + {"type": "local"}, + {"type": "remote"}, + {"type": "local", "command": ["server"], "url": "https://example.test/mcp"}, + {"type": "remote", "url": "https://example.test/mcp", "command": ["server"]}, + ], +) +def test_load_mcp_configs_rejects_missing_or_mixed_transport_fields(server): + config = AppConfig(provider_name="openai", env={}, project_config={"mcp": {"bad": server}}) + + with pytest.raises(McpConfigError): + load_mcp_configs(config) + + +def test_project_server_completely_overrides_same_named_global_server(): + config = AppConfig( + provider_name="openai", + env={}, + global_config={ + "mcp": { + "github": { + "type": "remote", + "url": "https://global.example/mcp", + "headers": {"X-Global": "yes"}, + }, + "global-only": {"type": "local", "command": ["global"]}, + } + }, + project_config={"mcp": {"github": {"type": "local", "command": ["project"]}}}, + ) + + assert config.mcp_config() == { + "github": {"type": "local", "command": ["project"]}, + "global-only": {"type": "local", "command": ["global"]}, + } + assert load_mcp_configs(config)[0] == McpLocalServerConfig( + name="github", command=("project",), env={}, allowed_tools=None + ) + + +def test_resolve_environment_placeholders_reports_only_missing_variable_name(): + with pytest.raises(McpConfigError) as error: + resolve_environment_placeholders( + {"Authorization": "Bearer {env:GITHUB_TOKEN}"}, + {"OTHER_SECRET": "do-not-leak"}, + ) + + assert str(error.value) == "缺少环境变量:GITHUB_TOKEN" + assert "do-not-leak" not in str(error.value) + + +def test_resolve_environment_placeholders_recurses_without_mutating_input(): + value = {"headers": {"Authorization": "Bearer {env:TOKEN}"}, "args": ["{env:HOST}"]} + + resolved = resolve_environment_placeholders(value, {"TOKEN": "secret", "HOST": "example.test"}) + + assert resolved == { + "headers": {"Authorization": "Bearer secret"}, + "args": ["example.test"], + } + assert value["headers"]["Authorization"] == "Bearer {env:TOKEN}" + + +@pytest.mark.parametrize("allowed_tools", ["calendar_*", [""], ["valid", 1], ["bad tool"]]) +def test_load_mcp_configs_validates_allowed_tools(allowed_tools): + config = AppConfig( + provider_name="openai", + env={}, + project_config={ + "mcp": { + "lark": { + "type": "local", + "command": ["lark-mcp"], + "allowed_tools": allowed_tools, + } + } + }, + ) + + with pytest.raises(McpConfigError, match="allowed_tools"): + load_mcp_configs(config) diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py new file mode 100644 index 0000000..9fb0b14 --- /dev/null +++ b/tests/test_mcp_integration.py @@ -0,0 +1,109 @@ +"""真实 MCP stdio 与远程配置边界的集成测试。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from firstcoder.mcp.adapter import adapt_mcp_tool +from firstcoder.mcp.manager import McpManager +from firstcoder.mcp.models import McpLocalServerConfig, McpRemoteServerConfig, McpToolDescription +from firstcoder.permissions.manager import PermissionManager +from firstcoder.permissions.policy import DefaultPermissionPolicy +from firstcoder.permissions.types import PermissionConfirmationChoice +from firstcoder.permissions.types import PermissionDecisionKind +from firstcoder.tools.permission_registry import PermissionAwareToolRegistry +from firstcoder.tools.registry import ToolRegistry + + +def test_stdio_echo_tool_requires_confirmation_then_executes_after_explicit_allow(tmp_path) -> None: + fixture = Path(__file__).parent / "fixtures" / "mcp_echo_server.py" + manager = McpManager( + ( + McpLocalServerConfig( + name="echo", + command=(sys.executable, str(fixture)), + timeout_ms=5_000, + ), + ) + ) + try: + manager.connect_all() + assert manager.doctor("echo").state == "connected" + discovered = dict(manager.tools())["echo"] + assert discovered.name == "echo" + + tool = adapt_mcp_tool(manager, "echo", discovered) + permissions = PermissionManager(policy=DefaultPermissionPolicy(tmp_path)) + registry = PermissionAwareToolRegistry(ToolRegistry([tool]), permissions) + + paused = registry.execute("mcp__echo__echo", {"message": "hello MCP"}) + + assert paused.data["requires_user_input"] is True + assert paused.data["permission_request"]["action"] == "mcp_tool" + assert paused.data["permission_request"]["target"] == "echo/echo" + + pending = registry.preflight("mcp__echo__echo", {"message": "hello MCP"}) + assert pending is not None + _, arguments, request, _ = pending + allowed = permissions.resolve_confirmation(request, PermissionConfirmationChoice.ALLOW_ONCE.value) + assert allowed.kind == PermissionDecisionKind.ALLOW + + result = registry.execute_without_permission_check("mcp__echo__echo", arguments) + + assert result.ok is True + assert result.content == "hello MCP" + finally: + manager.close() + + +class _RemoteTransport: + async def connect(self) -> None: + return None + + async def list_tools(self) -> tuple[McpToolDescription, ...]: + return (McpToolDescription("echo", "Echo text."),) + + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: + return {"content": [{"type": "text", "text": str(arguments["message"])}]} + + async def close(self) -> None: + return None + + +class _CapturingRemoteFactory: + def __init__(self) -> None: + self.config: McpRemoteServerConfig | None = None + + def create(self, config: McpLocalServerConfig | McpRemoteServerConfig) -> _RemoteTransport: + assert isinstance(config, McpRemoteServerConfig) + self.config = config + return _RemoteTransport() + + +def test_remote_config_forwards_url_and_headers_without_leaking_header_value() -> None: + secret = "Bearer secret-value-that-must-not-appear" + factory = _CapturingRemoteFactory() + manager = McpManager( + ( + McpRemoteServerConfig( + name="remote", + url="https://example.test/mcp", + headers={"Authorization": secret}, + ), + ), + transport_factory=factory, + ) + try: + manager.connect_all() + + assert factory.config is not None + assert factory.config.url == "https://example.test/mcp" + assert factory.config.headers == {"Authorization": secret} + status = manager.doctor("remote") + assert status is not None + assert status.state == "connected" + assert secret not in repr(status) + assert secret not in str(manager.statuses()) + finally: + manager.close() diff --git a/tests/test_mcp_manager.py b/tests/test_mcp_manager.py new file mode 100644 index 0000000..073cb7f --- /dev/null +++ b/tests/test_mcp_manager.py @@ -0,0 +1,198 @@ +"""MCP 管理器的连接生命周期测试。""" + +from __future__ import annotations + +import asyncio +import threading +import time +from dataclasses import dataclass, field + +from firstcoder.mcp.manager import McpManager +from firstcoder.mcp.models import McpLocalServerConfig, McpToolDescription +from firstcoder.mcp.transport import _stdio_environment + + +@dataclass +class FakeTransport: + """不依赖真实 MCP 服务端的传输替身。""" + + tools: tuple[McpToolDescription, ...] = () + connect_error: Exception | None = None + list_error: Exception | None = None + delay: float = 0 + connected: bool = False + closed: bool = False + calls: list[tuple[str, dict[str, object]]] = field(default_factory=list) + + async def connect(self) -> None: + if self.delay: + await asyncio.sleep(self.delay) + if self.connect_error: + raise self.connect_error + self.connected = True + + async def list_tools(self) -> tuple[McpToolDescription, ...]: + if self.list_error: + raise self.list_error + return self.tools + + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: + self.calls.append((name, arguments)) + return {"name": name, "arguments": arguments} + + async def close(self) -> None: + self.closed = True + + +class FakeTransportFactory: + def __init__(self, transports: dict[str, FakeTransport]) -> None: + self.transports = transports + + def create(self, config: McpLocalServerConfig) -> FakeTransport: + return self.transports[config.name] + + +def server(name: str, **kwargs: object) -> McpLocalServerConfig: + return McpLocalServerConfig(name=name, command=("fake",), **kwargs) + + +def test_connect_all_marks_disabled_server_without_creating_transport(): + factory = FakeTransportFactory({"disabled": FakeTransport()}) + manager = McpManager((server("disabled", enabled=False),), factory) + + manager.connect_all() + + assert manager.statuses() == (manager.doctor("disabled"),) + assert manager.doctor("disabled").state == "disabled" + assert manager.tools() == () + manager.close() + + +def test_connect_all_discovers_tools_and_call_tool_uses_connected_transport(): + discovered = McpToolDescription("calendar_list", "列出日程", {"type": "object"}) + transport = FakeTransport(tools=(discovered,)) + manager = McpManager((server("lark"),), FakeTransportFactory({"lark": transport})) + + manager.connect_all() + + assert manager.doctor("lark").state == "connected" + assert manager.doctor("lark").tool_count == 1 + assert manager.tools() == (("lark", discovered),) + assert manager.call_tool("lark", "calendar_list", {"limit": 3}) == { + "name": "calendar_list", + "arguments": {"limit": 3}, + } + manager.close() + assert transport.closed is True + + +def test_connect_all_filters_discovered_tools_using_allowed_tools_patterns(): + transport = FakeTransport( + tools=(McpToolDescription("calendar_list", None), McpToolDescription("doc_read", None)) + ) + manager = McpManager( + (server("lark", allowed_tools=("calendar_*",)),), + FakeTransportFactory({"lark": transport}), + ) + + manager.connect_all() + + assert manager.tools() == (("lark", McpToolDescription("calendar_list", None)),) + assert manager.doctor("lark").tool_count == 1 + manager.close() + + +def test_connect_all_keeps_other_servers_available_after_initialization_failure(): + broken = FakeTransport(list_error=RuntimeError("Bearer secret-value")) + healthy = FakeTransport(tools=(McpToolDescription("echo", None),)) + manager = McpManager( + (server("broken"), server("healthy")), + FakeTransportFactory({"broken": broken, "healthy": healthy}), + ) + + manager.connect_all() + + failed = manager.doctor("broken") + assert failed.state == "failed" + assert failed.error == "MCP 连接失败" + assert "secret-value" not in (failed.error or "") + assert manager.doctor("healthy").state == "connected" + manager.close() + + +def test_connect_all_marks_timeout_as_failed_and_does_not_raise(): + slow = FakeTransport(delay=0.2) + manager = McpManager( + (server("slow", timeout_ms=20),), FakeTransportFactory({"slow": slow}) + ) + + started = time.monotonic() + manager.connect_all() + + assert time.monotonic() - started < 0.15 + assert manager.doctor("slow").state == "failed" + assert manager.doctor("slow").error == "MCP 请求超时" + manager.close() + + +def test_connect_all_exposes_connecting_state_while_connection_is_in_progress(): + transport = FakeTransport(delay=0.1) + manager = McpManager((server("slow"),), FakeTransportFactory({"slow": transport})) + + import threading + + worker = threading.Thread(target=manager.connect_all) + worker.start() + seen_connecting = False + for _ in range(50): + if manager.doctor("slow").state == "connecting": + seen_connecting = True + break + time.sleep(0.005) + worker.join() + + assert seen_connecting is True + assert manager.doctor("slow").state == "connected" + manager.close() + + +def test_disconnect_marks_connected_servers_failed_and_removes_tools(): + transport = FakeTransport(tools=(McpToolDescription("echo", None),)) + manager = McpManager((server("echo"),), FakeTransportFactory({"echo": transport})) + manager.connect_all() + + manager.close() + + assert transport.closed is True + assert manager.doctor("echo").state == "failed" + assert manager.doctor("echo").error == "MCP 已断开" + assert manager.tools() == () + + +def test_stdio_environment_inherits_process_environment_and_overlays_server_values(monkeypatch): + monkeypatch.setenv("PATH", "/host/bin") + monkeypatch.setenv("INHERITED", "host-value") + + environment = _stdio_environment({"PATH": "/server/bin", "CONFIGURED": "server-value"}) + + assert environment["PATH"] == "/server/bin" + assert environment["INHERITED"] == "host-value" + assert environment["CONFIGURED"] == "server-value" + + +def test_close_is_idempotent_and_background_thread_closes_its_own_loop(monkeypatch): + manager = McpManager(()) + close_threads: list[int] = [] + loop_close = manager._loop.close + + def close_loop() -> None: + close_threads.append(threading.get_ident()) + loop_close() + + monkeypatch.setattr(manager._loop, "close", close_loop) + + manager.close() + manager.close() + + assert manager._thread.is_alive() is False + assert close_threads == [manager._thread.ident] diff --git a/tests/test_permissions_grants.py b/tests/test_permissions_grants.py index b6cf6da..7ff112e 100644 --- a/tests/test_permissions_grants.py +++ b/tests/test_permissions_grants.py @@ -251,3 +251,26 @@ def test_file_permission_grant_store_skips_invalid_entries(tmp_path) -> None: store = FilePermissionGrantStore(path) assert [grant.id for grant in store.list()] == ["grant_pytest"] + + +def test_mcp_tool_grant_matches_only_the_same_server_and_tool() -> None: + store = PermissionGrantStore( + [ + _grant( + "grant_lark_calendar_list", + action=PermissionAction.MCP_TOOL, + scope_type=PermissionScopeType.MCP_TOOL, + scope_value="lark/calendar_list", + ) + ] + ) + + assert store.matching_decision( + PermissionRequest(id="same", action=PermissionAction.MCP_TOOL, target="lark/calendar_list") + ) is not None + assert store.matching_decision( + PermissionRequest(id="other_tool", action=PermissionAction.MCP_TOOL, target="lark/calendar_create") + ) is None + assert store.matching_decision( + PermissionRequest(id="other_server", action=PermissionAction.MCP_TOOL, target="github/calendar_list") + ) is None diff --git a/tests/test_permissions_manager.py b/tests/test_permissions_manager.py index 04573fd..d8e8d49 100644 --- a/tests/test_permissions_manager.py +++ b/tests/test_permissions_manager.py @@ -288,3 +288,25 @@ def test_manager_unknown_choice_does_not_create_grant(tmp_path) -> None: assert decision.kind == PermissionDecisionKind.DENY assert decision.grant is None assert manager.grants.list() == [] + + +def test_manager_mcp_allow_always_is_exact_server_and_tool_scope(tmp_path) -> None: + manager = PermissionManager(policy=DefaultPermissionPolicy(tmp_path)) + request = PermissionRequest( + id="perm_lark_calendar_list", + action=PermissionAction.MCP_TOOL, + target="lark/calendar_list", + ) + + decision = manager.resolve_confirmation(request, "allow_always_same_scope") + + assert decision.grant is not None + assert decision.grant.scope_type == PermissionScopeType.MCP_TOOL + assert decision.grant.scope_value == "lark/calendar_list" + assert manager.preflight( + PermissionRequest( + id="perm_lark_calendar_create", + action=PermissionAction.MCP_TOOL, + target="lark/calendar_create", + ) + ).kind == PermissionDecisionKind.ASK diff --git a/tests/test_permissions_policy.py b/tests/test_permissions_policy.py index ca1df2e..f4c2932 100644 --- a/tests/test_permissions_policy.py +++ b/tests/test_permissions_policy.py @@ -255,3 +255,12 @@ def test_network_request_requires_confirmation(tmp_path) -> None: ) assert decision.kind == PermissionDecisionKind.ASK + + +def test_mcp_tool_requires_confirmation_except_bypass(tmp_path) -> None: + policy = DefaultPermissionPolicy(tmp_path) + request = _request(PermissionAction.MCP_TOOL, "lark/calendar_list") + + for mode in (PermissionMode.CONSERVATIVE, PermissionMode.STANDARD, PermissionMode.AGGRESSIVE): + assert policy.decide(request, mode=mode).kind == PermissionDecisionKind.ASK + assert policy.decide(request, mode=PermissionMode.BYPASS).kind == PermissionDecisionKind.ALLOW