Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -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/*
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

## 开发

Expand Down
83 changes: 83 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
@@ -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__<server>__<tool>` 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
`<server>/<tool>`. 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 <server>
```

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 <server>` 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
```
66 changes: 66 additions & 0 deletions docs/MCP.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# MCP 客户端

FirstCoder 可以从配置的 MCP server 发现工具,并通过现有工具注册表提供给 agent。MCP
在这里仅扩展“工具”:发现到的工具命名为 `mcp__<server>__<tool>`,并且始终经过原有权限管理器。

## 配置

可以在全局 `~/.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` 权限动作,目标精确为 `<server>/<tool>`。标准模式和
激进模式默认都会暂停等待确认;只有 bypass 模式会自动放行。“始终允许”也仅适用于
这个精确的 server/tool 对。

在 TUI 或行式交互客户端中使用:

```text
/mcp list
/mcp doctor <server>
```

它们会显示连接状态、发现工具数和安全错误,不会输出配置 headers、已解析的环境变量
或其他秘密。server 失败、禁用或超时都不会阻止 FirstCoder 启动,只是不注入工具。

## 排障

- 先独立确认命令能作为 MCP stdio server 运行;普通日志必须写到 stderr,不能污染 stdout。
- 确认 `command` 是 argv 列表,remote URL 是 HTTP/HTTPS,server/tool 名只能包含字母、数字、`_`、`-`。
- 修改配置后用 `/mcp doctor <server>` 检查;重启 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
```
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

## 评测与运行手册

Expand Down
1 change: 1 addition & 0 deletions firstcoder/agent/prompt_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"delete": "confirm",
"shell": "confirm",
"network": "confirm",
"mcp_tools": "confirm",
"env_secrets": "redact",
}

Expand Down
69 changes: 64 additions & 5 deletions firstcoder/app/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = ".",
Expand All @@ -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。

Expand All @@ -64,14 +113,21 @@ 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)
session = AgentSession.from_project(
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,
)
Expand All @@ -89,22 +145,22 @@ 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,
)
new_service = NewSessionService(
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,
)
Expand All @@ -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),
Expand All @@ -138,6 +195,7 @@ def create_firstcoder_app(
command_handler = CompositeCommandHandler(
[
HelpCommandHandler(),
McpCommandHandler(mcp_manager),
ModelCommandHandler(model_switcher),
session_handler,
context_handler,
Expand All @@ -155,6 +213,7 @@ def create_firstcoder_app(
provider_model=resolved_provider.model,
project_name=project_path.resolve().name,
),
on_shutdown=mcp_manager.close,
)


Expand Down
2 changes: 2 additions & 0 deletions firstcoder/app/help_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
("/compact", "Compact context now."),
("/mode", "Show permission mode."),
("/mode <conservative|standard|aggressive|bypass>", "Change permission mode."),
("/mcp list", "List MCP server status."),
("/mcp doctor <server>", "Inspect one MCP server."),
]


Expand Down
Loading
Loading