merge: resolve conflict with main in loop.go

Main reformatted the fallback.Execute call to multi-line (golines);
our branch renamed agent.Candidates → activeCandidates for routing.
Kept both: multi-line formatting + routing variable.
This commit is contained in:
xiaoen 2026-03-03 12:22:45 +08:00
commit 1a922c96a8
35 changed files with 2785 additions and 119 deletions

View file

@ -0,0 +1,42 @@
{
"permissions": {
"allow": [
"Bash(cd:*)",
"Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -v -count=1 2>&1)",
"Bash(cd /e/Project/picoclaw && golangci-lint run ./pkg/memory/... 2>&1)",
"Bash(cd /e/Project/picoclaw && golangci-lint run ./pkg/memory/... --fix 2>&1)",
"Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -count=1 2>&1)",
"Bash(cd /e/Project/picoclaw && go vet ./pkg/memory/... 2>&1)",
"Bash(cd /e/Project/picoclaw && go build ./... 2>&1)",
"Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -bench=. -benchmem -run=^$ 2>&1)",
"Bash(cd /e/Project/picoclaw && go test ./pkg/session/... -count=1 2>&1)",
"mcp__sequential-thinking__sequentialthinking",
"Bash(cd /e/Project/picoclaw && git push -u origin feat/jsonl-memory-store 2>&1)",
"Bash(head:*)",
"WebSearch",
"Bash(cd /e/Project/picoclaw && gh issue view 711 --comments 2>&1)",
"Bash(cd /e/Project/picoclaw && gh pr view 732 --comments 2>&1)",
"Bash(cd /e/Project/picoclaw && gh pr view 732 2>&1)",
"Bash(cd /e/Project/picoclaw && gh pr checks 732 2>&1)",
"Bash(echo no upstream remote:*)",
"Bash(cd /e/Project/picoclaw && git rebase upstream/main 2>&1)",
"Bash(cd /e/Project/picoclaw && go build ./pkg/memory/... 2>&1)",
"Bash(cd /e/Project/picoclaw && go test ./pkg/memory/... -count=1 -v 2>&1)",
"Bash(gh api:*)",
"Bash(git push:*)",
"Bash(go test:*)",
"Bash(find .:*)",
"Bash(golangci-lint run:*)",
"Bash(gh pr:*)",
"Bash(gh issue:*)",
"Bash(git fetch:*)",
"Bash(echo exit: $?:*)",
"WebFetch(domain:github.com)",
"Bash(git log:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(go build:*)",
"Bash(go vet:*)"
]
}
}

View file

@ -168,11 +168,11 @@ clean:
@echo "Clean complete" @echo "Clean complete"
## vet: Run go vet for static analysis ## vet: Run go vet for static analysis
vet: vet: generate
@$(GO) vet ./... @$(GO) vet ./...
## test: Test Go code ## test: Test Go code
test: test: generate
@$(GO) test ./... @$(GO) test ./...
## fmt: Format Go code ## fmt: Format Go code
@ -204,6 +204,44 @@ check: deps fmt vet test
run: build run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) @$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
## docker-build: Build Docker image (minimal Alpine-based)
docker-build:
@echo "Building minimal Docker image (Alpine-based)..."
docker compose -f docker/docker-compose.yml build picoclaw-agent picoclaw-gateway
## docker-build-full: Build Docker image with full MCP support (Node.js 24)
docker-build-full:
@echo "Building full-featured Docker image (Node.js 24)..."
docker compose -f docker/docker-compose.full.yml build picoclaw-agent picoclaw-gateway
## docker-test: Test MCP tools in Docker container
docker-test:
@echo "Testing MCP tools in Docker..."
@chmod +x scripts/test-docker-mcp.sh
@./scripts/test-docker-mcp.sh
## docker-run: Run picoclaw gateway in Docker (Alpine-based)
docker-run:
docker compose -f docker/docker-compose.yml --profile gateway up
## docker-run-full: Run picoclaw gateway in Docker (full-featured)
docker-run-full:
docker compose -f docker/docker-compose.full.yml --profile gateway up
## docker-run-agent: Run picoclaw agent in Docker (interactive, Alpine-based)
docker-run-agent:
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
## docker-run-agent-full: Run picoclaw agent in Docker (interactive, full-featured)
docker-run-agent-full:
docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent
## docker-clean: Clean Docker images and volumes
docker-clean:
docker compose -f docker/docker-compose.yml down -v
docker compose -f docker/docker-compose.full.yml down -v
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
## help: Show this help message ## help: Show this help message
help: help:
@echo "picoclaw Makefile" @echo "picoclaw Makefile"
@ -219,6 +257,8 @@ help:
@echo " make install # Install to ~/.local/bin" @echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin" @echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace" @echo " make install-skills # Install skills to workspace"
@echo " make docker-build # Build minimal Docker image"
@echo " make docker-test # Test MCP tools in Docker"
@echo "" @echo ""
@echo "Environment Variables:" @echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)" @echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"

View file

@ -0,0 +1,161 @@
# PicoClaw 26M2W3 社区开发者会议
> **PicoClaw的设计目标**:轻量高效,任意部署;简单易用,普惠大众;
> **致PicoClaw开发者**让我们携手加速AI奇点的到来共同创造并见证历史。
---
## 26M2W3 概况
### 成果
* **Github 表现**Star 17K+Merge 100+ PRContributors 70+
* **用户规模**:微信群 1600+Discord 1300+
* **开发者规模**:微信群 ~50Discord ~40
* **生态进展**PicoClaw 进入 Homebrew
* **工程进展**Provider 完成重构
* **特别鸣谢**daming, lxowalle 在假期的努力!
### 暴露的问题
* 第一次开展大规模社区协同开发,又是在假期期间,响应速度、社区协调、工程架构方面都暴露出了很多不足。
* PicoClaw 早期 vibe-coding 的快速实现架构在蜂拥而至的 PR 面前会迅速变成“屎山”和冲突地狱。
* 为尽快合并 PR未充分验证社区开发者的能力也没有提供合并指导规范过早给予 write 权限,在上面架构问题下更暴露出问题。
* 忙于以上 PR 协调问题,拖后了文档和宣发进度。特别是宣发问题,被不放春节假的海外开发者项目 zeroclaw 趁虚而入。
* ⚠️ **警惕币圈!** 尤其是 pump.fun 空气币,不要认领参与!
> **会议核心任务**:本次周会主要需要划分项目板块,认领板块负责人,制订下周计划。以下内容社区开发者可以继续添加遗漏的地方。
---
## 开发板块
### 仓库管理
* 新建 `dev` 分支,`main` 分支推送严格化。
* 完善 `CONTRIBUTING.md`
* **时区审核分工**
* GMT+8 附近时区审核(中国)
* GMT+0 附近时区审核(欧洲):**Huaaudio**
* GMT-8 附近时区审核(美洲)
* 仓库权限申请:联系 **zepan** 审核。
* Readme 中公布本次会议的分工人员表格,方便开发者找寻对应人员审核。
### Provider负责人daming
* **进度**:已重构完成。
* **计划**
* 梳理支持和计划支持的 provider 协议列表及进度计划。
* **插件系统探索**go 原生插件?(参考 [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin))
* **优化思路**:现在各种系统的 LLM provider 都在重复造轮子,而且每新增一个 provider 都得再改代码、重新发版才能支持。应该把专业的事交给专业的组件来负责。我开了个新的开源项目——`open-next-router`,采用 nginx 原子化配置的思想,新增 provider 无需改代码,新增配置文件即可支持,提供了 go 的 sdk 包可快速接入项目。PicoClaw 接入后可更聚焦于 agent 的实现而不是各种上游 provider 的适配,就能快其它 claw 一步。
### Channels负责人daming
* **进度**:正在重构。
* **计划**
* 梳理支持和计划支持的 channel 协议列表及进度计划。
* **附件支持讨论**:音频、视频、文件。
* 附件的生命周期应该由谁管理channel 应该只负责下载文件,然后交由 Agent 消费完成后管理生命周期?
* 音频转文字是否要迁移到 agent 层?或者说附件应该在哪一层被处理?
* 发送附件的方法如何拓展?添加新的方法?拓展原有 Message
* 群友建议的 **skill加channel**(参考 [nanoclaw skill](https://github.com/qwibitai/nanoclaw/blob/main/.claude/skills/add-telegram/SKILL.md))
* **插件系统讨论**。
* **架构优化**
* 抽离公共的 HTTP 服务器,采用 WebHook 通信的 channel 通过复用公共的服务器来节省资源和端口。
* Websocket 支持。
* 将路由相关字段(`peer_kind``peer_id`)从 metadata 中提升为 `InboundMessage` 的结构体字段。
* **状态管理**:聊天记录应该由 channel 管理还是 agent 管理?
### Agent负责人学欧
* Agent Loop 机制优化。
* **记忆系统**:引入 SQLite。
* **Multi-Agent / Swarm** 支持。
* **模型能力回退链**:在主模型不支持多模态时,使用多模态模型进行辅助。
### Tools负责人学欧
* 整理规范。
* 插件系统探索。
### Heartbeat / Status / Log 等负责人daming
* 完善心跳、状态和日志监控。
### Skill
* 搜索 skill 的 skill已合并 PR[PR #332](https://github.com/sipeed/picoclaw/pull/332)。
* **安全与维护**:探讨 skill 的维护和安全性问题,防范目前常见的投毒现象。
### MCP负责人evo
* **功能实现**:已有 PR [#376](https://github.com/sipeed/picoclaw/pull/376)、[#282](https://github.com/sipeed/picoclaw/pull/282)。
* 安卓手机操作支持。
* 浏览器操作 (`webmcp?` `action book?`):已有相关 PR ([agent-browser-tool](https://github.com/sipeed/picoclaw/tree/feat/agent-browser-tool))。
### 占用/效率优化(负责人:学欧)
* **目标**:优化内存占用与执行效率,希望控制在 **20M 以内**
* **分析**:分析各个版本之间的内存占用变化,分析各个模块的内存占用情况。
* **裁剪**:裁剪出最小版本,用于宣发。
### Security
* 响应并修复安全机构发送的漏洞警示。
* 参考 openclaw 等现有仓库的安全措施,加固 PicoClaw。
### AI CI负责人政宇
* 完善仓库的 CI 流程。
* 加入 AI review 等自动化流程。
* 完善发布流程、测试项目、release note、breaking change 记录。
* 根目录加上 `CLAUDE.md`
* 增加 `loongarch` & `deb/rpm` 支持。
### UX Testing
* 对 release 版进行一般性测试。
* 站在小白用户角度对使用交互提出意见建议,比如完善 PicoClaw onboard 流程。
* 展示性优化:比如启动时刷屏 ascii-art 的 PicoClaw 标识,增加用户拍摄视频时的辨识度。
### 文档工作
* 仓库 Readme 美化,仓库文档整理、规范。
* 整理所有 Channel、Provider 的实现支持列表。
* 针对小白用户的各个 Provider、Channel 详细手把手教程文档。
* 建设 Wiki 页面deepwiki?)。
---
## Release 待办事项 (Checklist)
- [ ] Provider
- [ ] Channel
- [ ] Agent
- [ ] Swarm
- [ ] Security
- [ ] MCP浏览器
- [ ] 文档
- [ ] Logo
- [ ] Metadata 问题解决
---
## 关于插件系统测试方案(补充记录)
测试了以下几种方案:
1. **内置的 plugin 模块**:不考虑。不支持 Windows 等平台 ([plugin](https://pkg.go.dev/plugin@go1.26.0))。
2. **hashicorp/go-plugin**:不考虑。占用资源过大,固件都增加了 2030M。
3. **net/rpc**client-server 模式):
* **优点**:支持热加载,插件可以保存运行状态。
* **缺点**:资源消耗较多(内存约增加 5M+,每个插件大小 10+M每个插件占用一个端口不太优雅。
4. **encoding/gob**(编译为可执行程序,由主程序调用并获取返回值):
* **优点**:支持热加载,消耗资源相对较少(测试固件大小增加了 376KB内存消耗增加了 640KB
* **缺点**:无法保存运行状态(应该可以用 socket 等方法来优化支持)。
---
## 宣发板块
### 社区运营
* **宣发物料/策划**:负责人 **zepan**,再寻求 1~2 位有网感的社区成员。
* 制作标准 Logo, Slogan。
* 制作具有传播性的图文/视频等。
* 策划互动性、传播性强的用户活动,产生用户内容。
* KOL 建联等其它宣发手段。
* **微信群运营**:负责人 **zepan**
* **推特运营**:负责人 **zepan**
* **Discord运营**:负责人 **OsmiumOP**;需要再找一个国内开发者盯一下,会给予 admin 权限。
* **其他渠道开拓**小红书、知乎、Reddit
* **Go社区联络大使**:负责人 **卓**
---
## 中期 TODO
* **桌面应用 / 安卓 APP**
* 架构讨论C/S 还是单程序?接口文档规范?
* **配套硬件**

View file

@ -0,0 +1,108 @@
# PicoClaw 贡献方向规划3月1日更新
## 个人情况
- Go 开发者,会 Python在学 AI Agent
- 已合并 PR#173多bug修复#186(安全加固)
- 已提交 PR#732JSONL session store等待 review
- 已关闭 PR#719SQLite 方案,被维护者建议改用 JSONL
---
## 项目当前态势3月1日
### 已完成的重构
- Provider 重构daming #492 — 完成
- Channel 重构 Phase 1alexhoshina #662 — 完成
- Channel 重构 Phase 2alexhoshina #877 (10,926行) — 2月27日合并
- Migrate 重构lxowalle #910 — 2月28日合并
### 正在进行的重构
- **Tools 系统重构**lxowalle PR #84650个文件— OPEN
- **Plugin 系统**gh-xj PR #936-9394个PR系列— OPEN
- **Agent 系统重构**alexhoshina Issue #772roadmap— 只有 issue还没有 PR
### 我的行动记录
- 2月24日#772 评论,将 PR #732 定位为 Agent 重构的 memory 子任务
- 3月1日#295 评论,提出模型路由设计方案
---
## 战略方向
### 方向 1智能模型路由#295)— 主攻 ✅ 代码已完成
**为什么选这个**
1. Zepan创始人亲自创建的 issueroadmap 标签
2. 有大量社区讨论但零 PR
3. 独立模块 `pkg/routing/`,不碰任何重构区文件
4. 面试价值极高
**已完成(分支 feat/model-routing**
- `pkg/routing/features.go` — ExtractFeatures5维结构评分纯语言无关
- `pkg/routing/classifier.go` — Classifier 接口 + RuleClassifier加权求和上限 1.0
- `pkg/routing/router.go` — RouterSelectModel阈值默认 0.35
- `pkg/routing/router_test.go` — 34 个测试,全部通过
- `pkg/config/config.go` — RoutingConfig 添加到 AgentDefaults
- `pkg/agent/instance.go` — 预计算 Router + LightCandidates
- `pkg/agent/loop.go` — selectCandidates helperturn 级别粘性路由
**3 个 commit773 行新增33 行修改0 个新依赖**
**配置**
```json
{
"agents": {
"defaults": {
"model": "claude-sonnet-4-6",
"routing": {
"enabled": true,
"light_model": "gemini-flash",
"threshold": 0.35
}
}
}
}
```
**下一步**:向上游 push 并开 PRPR body 引用 issue #295
### 方向 2JSONL Store 集成 — 等待时机
PR #732 已提交。等 Tools 重构 (#846) 合并后再做集成 PR。
已在 #772 评论建立关联。
### 方向 3sessions CLI 子命令(#575)— 备选快速 PR
如果需要一个快速能合并的 PR 来积累信任:
- `picoclaw sessions list/clear/export`
- 不碰任何重构区文件
- 实用性强
---
## 需要避开的区域
| 区域 | 原因 |
|------|------|
| Tools 系统 | lxowalle PR #846 正在重构 |
| Plugin 系统 | gh-xj PR #936-939 正在做 |
| Channel 任何东西 | alexhoshina 刚完成大重构 |
| Provider 配置 | daming 已定型 |
| MCP | 两个竞争 PR (#282, #376) |
| Hooks 基础 | gh-xj #936 包含 pkg/hooks/ |
| AgentLoop 拆分 | SaiBalusu-usf PR #699 |
| Tool pair 修复 | QuietyAwe PR #871 |
---
## 关键人物(更新)
| 人 | GitHub | 角色 | 最近活动 |
|---|--------|------|---------|
| Zepan | @Zepan | 创始人 | #806 WebUI issue |
| daming | @yinwm | Provider/审核 | 审核 PR #877 |
| alexhoshina | @alexhoshina | Channel+Agent 重构 | #877 合并,#772 发起 |
| lxowalle | @lxowalle | Tools+审核 | #846 Tools重构中 |
| gh-xj | @gh-xj | Plugin 系统 | #936-939 四个 PR |
| nikolasdehor | @nikolasdehor | 社区活跃评论者 | 每个 issue 都有他 |

View file

@ -925,7 +925,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors #### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key | | Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@ -937,6 +937,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
@ -1038,6 +1039,19 @@ This design also enables **multi-agent support** with flexible provider selectio
} }
``` ```
**LiteLLM Proxy**
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_key": "sk-..."
}
```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
#### Load Balancing #### Load Balancing
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them: Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:

View file

@ -5,6 +5,19 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const (
colorBlue = "[#3e5db9]"
colorRed = "[#d54646]"
banner = "\r\n[::b]" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"[:]"
)
func applyStyles() { func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
@ -24,14 +37,7 @@ func bannerView() *tview.TextView {
text.SetDynamicColors(true) text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter) text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
text.SetText( text.SetText(banner)
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
)
text.SetBorder(false) text.SetBorder(false)
return text return text
} }

View file

@ -48,7 +48,21 @@ func NewPicoclawCommand() *cobra.Command {
return cmd return cmd
} }
const (
colorBlue = "\033[1;38;2;62;93;185m"
colorRed = "\033[1;38;2;213;70;70m"
banner = "\r\n" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"\033[0m\r\n"
)
func main() { func main() {
fmt.Printf("%s", banner)
cmd := NewPicoclawCommand() cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)

View file

@ -243,6 +243,63 @@
"cron": { "cron": {
"exec_timeout_minutes": 5 "exec_timeout_minutes": 5
}, },
"mcp": {
"enabled": false,
"servers": {
"filesystem": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
},
"github": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"brave-search": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
],
"env": {
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
}
},
"postgres": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://user:password@localhost/dbname"
]
},
"slack": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
}
}
}
},
"exec": { "exec": {
"enable_deny_patterns": false, "enable_deny_patterns": false,
"custom_deny_patterns": [] "custom_deny_patterns": []

44
docker/Dockerfile.full Normal file
View file

@ -0,0 +1,44 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN make build
# ============================================================
# Stage 2: Node.js-based runtime with full MCP support
# ============================================================
FROM node:24-alpine3.23
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
curl \
git \
python3 \
py3-pip
# Install uv and symlink to system path
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
uv --version
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Create picoclaw home directory
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -0,0 +1,44 @@
services:
# ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml run --rm picoclaw-agent -m "Hello"
# ─────────────────────────────────────────────
picoclaw-agent:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-agent-full
profiles:
- agent
volumes:
- ../config/config.json:/root/.picoclaw/config.json:ro
- picoclaw-workspace:/root/.picoclaw/workspace
- picoclaw-npm-cache:/root/.npm # npm cache for faster MCP server installs
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) - Full MCP Support
# docker compose -f docker/docker-compose.full.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: picoclaw-gateway-full
restart: unless-stopped
profiles:
- gateway
volumes:
# Configuration file
- ../config/config.json:/root/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/root/.picoclaw/workspace
# NPM cache for faster MCP server installs
- picoclaw-npm-cache:/root/.npm
command: ["gateway"]
volumes:
picoclaw-workspace:
picoclaw-npm-cache: # Cache npm packages to speed up MCP server installations

View file

@ -8,6 +8,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
{ {
"tools": { "tools": {
"web": { ... }, "web": { ... },
"mcp": { ... },
"exec": { ... }, "exec": { ... },
"cron": { ... }, "cron": { ... },
"skills": { ... } "skills": { ... }
@ -21,35 +22,35 @@ Web tools are used for web search and fetching.
### Brave ### Brave
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ------ | ------- | ------------------------- |
| `enabled` | bool | false | Enable Brave search | | `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key | | `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
### DuckDuckGo ### DuckDuckGo
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ---- | ------- | ------------------------- |
| `enabled` | bool | true | Enable DuckDuckGo search | | `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
### Perplexity ### Perplexity
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ------------- | ------ | ------- | ------------------------- |
| `enabled` | bool | false | Enable Perplexity search | | `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key | | `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
## Exec Tool ## Exec Tool
The exec tool is used to execute shell commands. The exec tool is used to execute shell commands.
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------- | ----- | ------- | ------------------------------------------ |
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
### Functionality ### Functionality
@ -80,10 +81,7 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": { "tools": {
"exec": { "exec": {
"enable_deny_patterns": true, "enable_deny_patterns": true,
"custom_deny_patterns": [ "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
]
} }
} }
} }
@ -93,9 +91,84 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks. The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------- | ---- | ------- | ---------------------------------------------- |
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers.
### Global Config
| Config | Type | Default | Description |
| --------- | ------ | ------- | ----------------------------------- |
| `enabled` | bool | false | Enable MCP integration globally |
| `servers` | object | `{}` | Map of server name to server config |
### Per-Server Config
| Config | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------ |
| `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
| `args` | array | no | Command arguments for stdio transport |
| `env` | object | no | Environment variables for stdio process |
| `env_file` | string | no | Path to environment file for stdio process |
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
### Transport Behavior
- If `type` is omitted, transport is auto-detected:
- `url` is set → `sse`
- `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers.
### Configuration Examples
#### 1) Stdio MCP server
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
#### 2) Remote SSE/HTTP MCP server
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"remote-mcp": {
"enabled": true,
"type": "sse",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
}
}
```
## Skills Tool ## Skills Tool
@ -103,13 +176,13 @@ The skills tool configures skill discovery and installation via registries like
### Registries ### Registries
| Config | Type | Default | Description | | Config | Type | Default | Description |
|--------|------|---------|-------------| | ---------------------------------- | ------ | -------------------- | ----------------------- |
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | | `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | | `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | | `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example ### Configuration Example
@ -136,8 +209,10 @@ The skills tool configures skill discovery and installation via registries like
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`: All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
For example: For example:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` - `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` - `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
Note: Array-type environment variables are not currently supported and must be set via the config file. Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.

2
go.mod
View file

@ -12,6 +12,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/mymmrac/telego v1.6.0 github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
@ -81,6 +82,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.7 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect

6
go.sum
View file

@ -66,6 +66,8 @@ github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -130,6 +132,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@ -212,6 +216,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

View file

@ -23,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
@ -58,7 +59,11 @@ type processOptions struct {
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { func NewAgentLoop(
cfg *config.Config,
msgBus *bus.MessageBus,
provider providers.LLMProvider,
) *AgentLoop {
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents // Register shared tools to all agents
@ -170,6 +175,71 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true) al.running.Store(true)
// Initialize MCP servers for all agents
if al.cfg.Tools.MCP.Enabled {
mcpManager := mcp.NewManager()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
} else {
// Ensure MCP connections are cleaned up on exit, only if initialization succeeded
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
agent.Tools.Register(mcpTool)
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
}
}
for al.running.Load() { for al.running.Load() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -310,7 +380,10 @@ func (al *AgentLoop) RecordLastChatID(chatID string) error {
return al.state.SetLastChatID(chatID) return al.state.SetLastChatID(chatID)
} }
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { func (al *AgentLoop) ProcessDirect(
ctx context.Context,
content, sessionKey string,
) (string, error) {
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
} }
@ -331,7 +404,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context. // Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context,
content, channel, chatID string,
) (string, error) {
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
@ -356,13 +432,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} else { } else {
logContent = utils.Truncate(msg.Content, 80) logContent = utils.Truncate(msg.Content, 80)
} }
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), logger.InfoCF(
"agent",
fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
map[string]any{ map[string]any{
"channel": msg.Channel, "channel": msg.Channel,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"sender_id": msg.SenderID, "sender_id": msg.SenderID,
"session_key": msg.SessionKey, "session_key": msg.SessionKey,
}) },
)
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {
@ -423,9 +502,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}) })
} }
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processSystemMessage(
ctx context.Context,
msg bus.InboundMessage,
) (string, error) {
if msg.Channel != "system" { if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) return "", fmt.Errorf(
"processSystemMessage called with non-system message channel: %s",
msg.Channel,
)
} }
logger.InfoCF("agent", "Processing system message", logger.InfoCF("agent", "Processing system message",
@ -483,14 +568,22 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
} }
// runAgentLoop is the core message processing logic. // runAgentLoop is the core message processing logic.
func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { func (al *AgentLoop) runAgentLoop(
ctx context.Context,
agent *AgentInstance,
opts processOptions,
) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels) // 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" { if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent) // Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) { if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil { if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) logger.WarnCF(
"agent",
"Failed to record last channel",
map[string]any{"error": err.Error()},
)
} }
} }
} }
@ -572,7 +665,10 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
return "" return ""
} }
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { func (al *AgentLoop) handleReasoning(
ctx context.Context,
reasoningContent, channelName, channelID string,
) {
if reasoningContent == "" || channelName == "" || channelID == "" { if reasoningContent == "" || channelName == "" || channelID == "" {
return return
} }
@ -671,22 +767,33 @@ func (al *AgentLoop) runLLMIteration(
callLLM := func() (*providers.LLMResponse, error) { callLLM := func() (*providers.LLMResponse, error) {
if len(activeCandidates) > 1 && al.fallback != nil { if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(ctx, activeCandidates, fbResult, fbErr := al.fallback.Execute(
ctx,
activeCandidates,
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{ return agent.Provider.Chat(
"max_tokens": agent.MaxTokens, ctx,
"temperature": agent.Temperature, messages,
"prompt_cache_key": agent.ID, providerToolDefs,
}) model,
map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
"prompt_cache_key": agent.ID,
},
)
}, },
) )
if fbErr != nil { if fbErr != nil {
return nil, fbErr return nil, fbErr
} }
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", logger.InfoCF(
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), "agent",
map[string]any{"agent_id": agent.ID, "iteration": iteration}) fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
map[string]any{"agent_id": agent.ID, "iteration": iteration},
)
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
@ -737,10 +844,14 @@ func (al *AgentLoop) runLLMIteration(
} }
if isContextError && retry < maxRetries { if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ logger.WarnCF(
"error": err.Error(), "agent",
"retry": retry, "Context window error detected, attempting compression",
}) map[string]any{
"error": err.Error(),
"retry": retry,
},
)
if retry == 0 && !constants.IsInternalChannel(opts.Channel) { if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
@ -772,7 +883,12 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
} }
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) go al.handleReasoning(
ctx,
response.Reasoning,
opts.Channel,
al.targetReasoningChannelID(opts.Channel),
)
logger.DebugCF("agent", "LLM response", logger.DebugCF("agent", "LLM response",
map[string]any{ map[string]any{
@ -1105,7 +1221,11 @@ func formatMessagesForLog(messages []providers.Message) string {
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil { if tc.Function != nil {
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) fmt.Fprintf(
&sb,
" Arguments: %s\n",
utils.Truncate(tc.Function.Arguments, 200),
)
} }
} }
} }
@ -1134,7 +1254,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 { if len(tool.Function.Parameters) > 0 {
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) fmt.Fprintf(
&sb,
" Parameters: %s\n",
utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
)
} }
} }
sb.WriteString("]") sb.WriteString("]")
@ -1231,7 +1355,9 @@ func (al *AgentLoop) summarizeBatch(
existingSummary string, existingSummary string,
) (string, error) { ) (string, error) {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") sb.WriteString(
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
)
if existingSummary != "" { if existingSummary != "" {
sb.WriteString("Existing context: ") sb.WriteString("Existing context: ")
sb.WriteString(existingSummary) sb.WriteString(existingSummary)

View file

@ -7,12 +7,12 @@ import (
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegohandler"
th "github.com/mymmrac/telego/telegohandler" th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil" tu "github.com/mymmrac/telego/telegoutil"
@ -41,7 +41,7 @@ var (
type TelegramChannel struct { type TelegramChannel struct {
*channels.BaseChannel *channels.BaseChannel
bot *telego.Bot bot *telego.Bot
bh *telegohandler.BotHandler bh *th.BotHandler
commands TelegramCommander commands TelegramCommander
config *config.Config config *config.Config
chatIDs map[string]int64 chatIDs map[string]int64
@ -101,6 +101,12 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.initBotCommands(c.ctx); err != nil {
logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{
"error": err.Error(),
})
}
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
Timeout: 30, Timeout: 30,
}) })
@ -109,20 +115,19 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err) return fmt.Errorf("failed to start long polling: %w", err)
} }
bh, err := telegohandler.NewBotHandler(c.bot, updates) bh, err := th.NewBotHandler(c.bot, updates)
if err != nil { if err != nil {
c.cancel() c.cancel()
return fmt.Errorf("failed to create bot handler: %w", err) return fmt.Errorf("failed to create bot handler: %w", err)
} }
c.bh = bh c.bh = bh
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
c.commands.Help(ctx, message)
return nil
}, th.CommandEqual("help"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Start(ctx, message) return c.commands.Start(ctx, message)
}, th.CommandEqual("start")) }, th.CommandEqual("start"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Help(ctx, message)
}, th.CommandEqual("help"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Show(ctx, message) return c.commands.Show(ctx, message)
@ -141,7 +146,13 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
"username": c.bot.Username(), "username": c.bot.Username(),
}) })
go bh.Start() go func() {
if err = bh.Start(); err != nil {
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
"error": err.Error(),
})
}
}()
return nil return nil
} }
@ -152,7 +163,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
// Stop the bot handler // Stop the bot handler
if c.bh != nil { if c.bh != nil {
c.bh.Stop() _ = c.bh.StopWithContext(ctx)
} }
// Cancel our context (stops long polling) // Cancel our context (stops long polling)
@ -163,6 +174,51 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil return nil
} }
func (c *TelegramChannel) initBotCommands(ctx context.Context) error {
currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{
Scope: tu.ScopeDefault(),
})
if err != nil {
return fmt.Errorf("get commands: %w", err)
}
commands := []telego.BotCommand{
{
Command: "start",
Description: "Start the bot",
},
{
Command: "help",
Description: "Show a help message",
},
{
Command: "show",
Description: "Show current configuration",
},
{
Command: "list",
Description: "List available options",
},
}
// Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed
if !slices.Equal(currentCommands, commands) {
logger.InfoC("telegram", "Updating bot commands")
err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
Commands: commands,
Scope: tu.ScopeDefault(),
})
if err != nil {
return fmt.Errorf("set commands: %w", err)
}
} else {
logger.DebugC("telegram", "Bot commands are up to date")
}
return nil
}
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning

View file

@ -38,8 +38,7 @@ type WeComAppChannel struct {
tokenMu sync.RWMutex tokenMu sync.RWMutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed processedMsgs *MessageDeduplicator
msgMu sync.RWMutex
} }
// WeComXMLMessage represents the XML message structure from WeCom // WeComXMLMessage represents the XML message structure from WeCom
@ -144,7 +143,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
client: &http.Client{Timeout: clientTimeout}, client: &http.Client{Timeout: clientTimeout},
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
processedMsgs: make(map[string]bool), processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil }, nil
} }
@ -607,23 +606,12 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
// Message deduplication: Use msg_id to prevent duplicate processing // Message deduplication: Use msg_id to prevent duplicate processing
// As per WeCom documentation, use msg_id for deduplication // As per WeCom documentation, use msg_id for deduplication
msgID := fmt.Sprintf("%d", msg.MsgId) msgID := fmt.Sprintf("%d", msg.MsgId)
c.msgMu.Lock() if !c.processedMsgs.MarkMessageProcessed(msgID) {
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID, "msg_id": msgID,
}) })
return return
} }
c.processedMsgs[msgID] = true
// Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// deduplicated.
if len(c.processedMsgs) > 1000 {
c.processedMsgs = make(map[string]bool)
c.processedMsgs[msgID] = true
}
c.msgMu.Unlock()
senderID := msg.FromUserName senderID := msg.FromUserName
chatID := senderID // WeCom App uses user ID as chat ID for direct messages chatID := senderID // WeCom App uses user ID as chat ID for direct messages

View file

@ -9,7 +9,6 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -28,8 +27,7 @@ type WeComBotChannel struct {
client *http.Client client *http.Client
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed processedMsgs *MessageDeduplicator
msgMu sync.RWMutex
} }
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
@ -108,7 +106,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
client: &http.Client{Timeout: clientTimeout}, client: &http.Client{Timeout: clientTimeout},
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
processedMsgs: make(map[string]bool), processedMsgs: NewMessageDeduplicator(wecomMaxProcessedMessages),
}, nil }, nil
} }
@ -330,23 +328,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
// Message deduplication: Use msg_id to prevent duplicate processing // Message deduplication: Use msg_id to prevent duplicate processing
msgID := msg.MsgID msgID := msg.MsgID
c.msgMu.Lock() if !c.processedMsgs.MarkMessageProcessed(msgID) {
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID, "msg_id": msgID,
}) })
return return
} }
c.processedMsgs[msgID] = true
// Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// deduplicated.
if len(c.processedMsgs) > 1000 {
c.processedMsgs = make(map[string]bool)
c.processedMsgs[msgID] = true
}
c.msgMu.Unlock()
senderID := msg.From.UserID senderID := msg.From.UserID

View file

@ -0,0 +1,54 @@
package wecom
import "sync"
const wecomMaxProcessedMessages = 1000
// MessageDeduplicator provides thread-safe message deduplication using a circular queue (ring buffer)
// combined with a hash map. This ensures fast O(1) lookups while naturally evicting the oldest
// messages without causing "amnesia cliffs" when the limit is reached.
type MessageDeduplicator struct {
mu sync.Mutex
msgs map[string]bool
ring []string
idx int
max int
}
// NewMessageDeduplicator creates a new deduplicator with the specified capacity.
func NewMessageDeduplicator(maxEntries int) *MessageDeduplicator {
if maxEntries <= 0 {
maxEntries = wecomMaxProcessedMessages
}
return &MessageDeduplicator{
msgs: make(map[string]bool, maxEntries),
ring: make([]string, maxEntries),
max: maxEntries,
}
}
// MarkMessageProcessed marks msgID as processed and returns false for duplicates.
func (d *MessageDeduplicator) MarkMessageProcessed(msgID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
// 1. Check for duplicate
if d.msgs[msgID] {
return false
}
// 2. Evict the oldest message at our current ring position (if any)
oldestID := d.ring[d.idx]
if oldestID != "" {
delete(d.msgs, oldestID)
}
// 3. Store the new message
d.msgs[msgID] = true
d.ring[d.idx] = msgID
// 4. Advance the circle queue index
d.idx = (d.idx + 1) % d.max
return true
}

View file

@ -0,0 +1,83 @@
package wecom
import (
"sync"
"testing"
)
func TestMessageDeduplicator_DuplicateDetection(t *testing.T) {
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
if ok := d.MarkMessageProcessed("msg-1"); !ok {
t.Fatalf("first message should be accepted")
}
if ok := d.MarkMessageProcessed("msg-1"); ok {
t.Fatalf("duplicate message should be rejected")
}
}
func TestMessageDeduplicator_ConcurrentSameMessage(t *testing.T) {
d := NewMessageDeduplicator(wecomMaxProcessedMessages)
const goroutines = 64
var wg sync.WaitGroup
wg.Add(goroutines)
results := make(chan bool, goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
results <- d.MarkMessageProcessed("msg-concurrent")
}()
}
wg.Wait()
close(results)
successes := 0
for ok := range results {
if ok {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly 1 successful mark, got %d", successes)
}
}
func TestMessageDeduplicator_CircularQueueEviction(t *testing.T) {
// Create a deduplicator with a very small capacity to test eviction easily.
capacity := 3
d := NewMessageDeduplicator(capacity)
// Fill the queue.
d.MarkMessageProcessed("msg-1")
d.MarkMessageProcessed("msg-2")
d.MarkMessageProcessed("msg-3")
// At this point, the queue is full. msg-1 is the oldest.
if len(d.msgs) != 3 {
t.Fatalf("expected map size to be 3, got %d", len(d.msgs))
}
// This should evict msg-1 and add msg-4.
if ok := d.MarkMessageProcessed("msg-4"); !ok {
t.Fatalf("msg-4 should be accepted")
}
if len(d.msgs) != 3 {
t.Fatalf("expected map size to remain at max capacity (3), got %d", len(d.msgs))
}
// msg-1 should now be forgotten (evicted).
if ok := d.MarkMessageProcessed("msg-1"); !ok {
t.Fatalf("msg-1 should be accepted again because it was evicted")
}
// msg-2 should have been evicted when we added msg-1 back.
if ok := d.MarkMessageProcessed("msg-2"); !ok {
t.Fatalf("msg-2 should be accepted again because it was evicted")
}
}

View file

@ -412,6 +412,7 @@ type DevicesConfig struct {
type ProvidersConfig struct { type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"` Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"` OpenAI OpenAIProviderConfig `json:"openai"`
LiteLLM ProviderConfig `json:"litellm"`
OpenRouter ProviderConfig `json:"openrouter"` OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"` Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"` Zhipu ProviderConfig `json:"zhipu"`
@ -435,6 +436,7 @@ type ProvidersConfig struct {
func (p ProvidersConfig) IsEmpty() bool { func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" && return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" && p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" && p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" && p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" && p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
@ -578,6 +580,7 @@ type ToolsConfig struct {
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"`
} }
type SkillsToolsConfig struct { type SkillsToolsConfig struct {
@ -607,6 +610,34 @@ type ClawHubRegistryConfig struct {
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
} }
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
Args []string `json:"args,omitempty"`
// Env are environment variables to set for the server process (stdio only)
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
// Enabled globally enables/disables MCP integration
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()

View file

@ -361,6 +361,10 @@ func DefaultConfig() *Config {
TTLSeconds: 300, TTLSeconds: 300,
}, },
}, },
MCP: MCPConfig{
Enabled: false,
Servers: map[string]MCPServerConfig{},
},
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Enabled: true, Enabled: true,

View file

@ -88,6 +88,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true }, true
}, },
}, },
{
providerNames: []string{"litellm"},
protocol: "litellm",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "litellm",
Model: "litellm/auto",
APIKey: p.LiteLLM.APIKey,
APIBase: p.LiteLLM.APIBase,
Proxy: p.LiteLLM.Proxy,
RequestTimeout: p.LiteLLM.RequestTimeout,
}, true
},
},
{ {
providerNames: []string{"openrouter"}, providerNames: []string{"openrouter"},
protocol: "openrouter", protocol: "openrouter",

View file

@ -63,6 +63,33 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
} }
} }
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
LiteLLM: ProviderConfig{
APIKey: "litellm-key",
APIBase: "http://localhost:4000/v1",
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].ModelName != "litellm" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm")
}
if result[0].Model != "litellm/auto" {
t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto")
}
if result[0].APIBase != "http://localhost:4000/v1" {
t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1")
}
}
func TestConvertProvidersToModelList_Multiple(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &Config{ cfg := &Config{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
@ -115,6 +142,7 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
cfg := &Config{ cfg := &Config{
Providers: ProvidersConfig{ Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}}, OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
Anthropic: ProviderConfig{APIKey: "key2"}, Anthropic: ProviderConfig{APIKey: "key2"},
OpenRouter: ProviderConfig{APIKey: "key3"}, OpenRouter: ProviderConfig{APIKey: "key3"},
Groq: ProviderConfig{APIKey: "key4"}, Groq: ProviderConfig{APIKey: "key4"},
@ -137,9 +165,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
result := ConvertProvidersToModelList(cfg) result := ConvertProvidersToModelList(cfg)
// All 18 providers should be converted // All 19 providers should be converted
if len(result) != 18 { if len(result) != 19 {
t.Errorf("len(result) = %d, want 18", len(result)) t.Errorf("len(result) = %d, want 19", len(result))
} }
} }

526
pkg/mcp/manager.go Normal file
View file

@ -0,0 +1,526 @@
package mcp
import (
"bufio"
"context"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// headerTransport is an http.RoundTripper that adds custom headers to requests
type headerTransport struct {
base http.RoundTripper
headers map[string]string
}
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
req = req.Clone(req.Context())
// Add custom headers
for key, value := range t.headers {
req.Header.Set(key, value)
}
// Use the base transport
base := t.base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(req)
}
// loadEnvFile loads environment variables from a file in .env format
// Each line should be in the format: KEY=value
// Lines starting with # are comments
// Empty lines are ignored
func loadEnvFile(path string) (map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open env file: %w", err)
}
defer file.Close()
envVars := make(map[string]string)
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse KEY=value
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid format at line %d: %s", lineNum, line)
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "" {
return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
}
// Remove surrounding quotes if present
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
envVars[key] = value
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading env file: %w", err)
}
return envVars, nil
}
// ServerConnection represents a connection to an MCP server
type ServerConnection struct {
Name string
Client *mcp.Client
Session *mcp.ClientSession
Tools []*mcp.Tool
}
// Manager manages multiple MCP server connections
type Manager struct {
servers map[string]*ServerConnection
mu sync.RWMutex
closed bool
wg sync.WaitGroup // tracks in-flight CallTool calls
}
// NewManager creates a new MCP manager
func NewManager() *Manager {
return &Manager{
servers: make(map[string]*ServerConnection),
}
}
// LoadFromConfig loads MCP servers from configuration
func (m *Manager) LoadFromConfig(ctx context.Context, cfg *config.Config) error {
return m.LoadFromMCPConfig(ctx, cfg.Tools.MCP, cfg.WorkspacePath())
}
// LoadFromMCPConfig loads MCP servers from MCP configuration and workspace path.
// This is the minimal dependency version that doesn't require the full Config object.
func (m *Manager) LoadFromMCPConfig(
ctx context.Context,
mcpCfg config.MCPConfig,
workspacePath string,
) error {
if !mcpCfg.Enabled {
logger.InfoCF("mcp", "MCP integration is disabled", nil)
return nil
}
if len(mcpCfg.Servers) == 0 {
logger.InfoCF("mcp", "No MCP servers configured", nil)
return nil
}
logger.InfoCF("mcp", "Initializing MCP servers",
map[string]any{
"count": len(mcpCfg.Servers),
})
var wg sync.WaitGroup
errs := make(chan error, len(mcpCfg.Servers))
enabledCount := 0
for name, serverCfg := range mcpCfg.Servers {
if !serverCfg.Enabled {
logger.DebugCF("mcp", "Skipping disabled server",
map[string]any{
"server": name,
})
continue
}
enabledCount++
wg.Add(1)
go func(name string, serverCfg config.MCPServerConfig, workspace string) {
defer wg.Done()
// Resolve relative envFile paths relative to workspace
if serverCfg.EnvFile != "" && !filepath.IsAbs(serverCfg.EnvFile) {
if workspace == "" {
err := fmt.Errorf(
"workspace path is empty while resolving relative envFile %q for server %s",
serverCfg.EnvFile,
name,
)
logger.ErrorCF("mcp", "Invalid MCP server configuration",
map[string]any{
"server": name,
"env_file": serverCfg.EnvFile,
"error": err.Error(),
})
errs <- err
return
}
serverCfg.EnvFile = filepath.Join(workspace, serverCfg.EnvFile)
}
if err := m.ConnectServer(ctx, name, serverCfg); err != nil {
logger.ErrorCF("mcp", "Failed to connect to MCP server",
map[string]any{
"server": name,
"error": err.Error(),
})
errs <- fmt.Errorf("failed to connect to server %s: %w", name, err)
}
}(name, serverCfg, workspacePath)
}
wg.Wait()
close(errs)
// Collect errors
var allErrors []error
for err := range errs {
allErrors = append(allErrors, err)
}
connectedCount := len(m.GetServers())
// If all enabled servers failed to connect, return aggregated error
if enabledCount > 0 && connectedCount == 0 {
logger.ErrorCF("mcp", "All MCP servers failed to connect",
map[string]any{
"failed": len(allErrors),
"total": enabledCount,
})
return errors.Join(allErrors...)
}
if len(allErrors) > 0 {
logger.WarnCF("mcp", "Some MCP servers failed to connect",
map[string]any{
"failed": len(allErrors),
"connected": connectedCount,
"total": enabledCount,
})
// Don't fail completely if some servers successfully connected
}
logger.InfoCF("mcp", "MCP server initialization complete",
map[string]any{
"connected": connectedCount,
"total": enabledCount,
})
return nil
}
// ConnectServer connects to a single MCP server
func (m *Manager) ConnectServer(
ctx context.Context,
name string,
cfg config.MCPServerConfig,
) error {
logger.InfoCF("mcp", "Connecting to MCP server",
map[string]any{
"server": name,
"command": cfg.Command,
"args_count": len(cfg.Args),
})
// Create client
client := mcp.NewClient(&mcp.Implementation{
Name: "picoclaw",
Version: "1.0.0",
}, nil)
// Create transport based on configuration
// Auto-detect transport type if not explicitly specified
var transport mcp.Transport
transportType := cfg.Type
// Auto-detect: if URL is provided, use SSE; if command is provided, use stdio
if transportType == "" {
if cfg.URL != "" {
transportType = "sse"
} else if cfg.Command != "" {
transportType = "stdio"
} else {
return fmt.Errorf("either URL or command must be provided")
}
}
switch transportType {
case "sse", "http":
if cfg.URL == "" {
return fmt.Errorf("URL is required for SSE/HTTP transport")
}
logger.DebugCF("mcp", "Using SSE/HTTP transport",
map[string]any{
"server": name,
"url": cfg.URL,
})
sseTransport := &mcp.StreamableClientTransport{
Endpoint: cfg.URL,
}
// Add custom headers if provided
if len(cfg.Headers) > 0 {
// Create a custom HTTP client with header-injecting transport
sseTransport.HTTPClient = &http.Client{
Transport: &headerTransport{
base: http.DefaultTransport,
headers: cfg.Headers,
},
}
logger.DebugCF("mcp", "Added custom HTTP headers",
map[string]any{
"server": name,
"header_count": len(cfg.Headers),
})
}
transport = sseTransport
case "stdio":
if cfg.Command == "" {
return fmt.Errorf("command is required for stdio transport")
}
logger.DebugCF("mcp", "Using stdio transport",
map[string]any{
"server": name,
"command": cfg.Command,
})
// Create command with context
cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
// Build environment variables with proper override semantics
// Use a map to ensure config variables override file variables
envMap := make(map[string]string)
// Start with parent process environment
for _, e := range cmd.Environ() {
if idx := strings.Index(e, "="); idx > 0 {
envMap[e[:idx]] = e[idx+1:]
}
}
// Load environment variables from file if specified
if cfg.EnvFile != "" {
envVars, err := loadEnvFile(cfg.EnvFile)
if err != nil {
return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
}
for k, v := range envVars {
envMap[k] = v
}
logger.DebugCF("mcp", "Loaded environment variables from file",
map[string]any{
"server": name,
"envFile": cfg.EnvFile,
"var_count": len(envVars),
})
}
// Environment variables from config override those from file
for k, v := range cfg.Env {
envMap[k] = v
}
// Convert map to slice
env := make([]string, 0, len(envMap))
for k, v := range envMap {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
transport = &mcp.CommandTransport{Command: cmd}
default:
return fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",
transportType,
)
}
// Connect to server
session, err := client.Connect(ctx, transport, nil)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
// Get server info
initResult := session.InitializeResult()
logger.InfoCF("mcp", "Connected to MCP server",
map[string]any{
"server": name,
"serverName": initResult.ServerInfo.Name,
"serverVersion": initResult.ServerInfo.Version,
"protocol": initResult.ProtocolVersion,
})
// List available tools if supported
var tools []*mcp.Tool
if initResult.Capabilities.Tools != nil {
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
logger.WarnCF("mcp", "Error listing tool",
map[string]any{
"server": name,
"error": err.Error(),
})
continue
}
tools = append(tools, tool)
}
logger.InfoCF("mcp", "Listed tools from MCP server",
map[string]any{
"server": name,
"toolCount": len(tools),
})
}
// Store connection
m.mu.Lock()
m.servers[name] = &ServerConnection{
Name: name,
Client: client,
Session: session,
Tools: tools,
}
m.mu.Unlock()
return nil
}
// GetServers returns all connected servers
func (m *Manager) GetServers() map[string]*ServerConnection {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]*ServerConnection, len(m.servers))
for k, v := range m.servers {
result[k] = v
}
return result
}
// GetServer returns a specific server connection
func (m *Manager) GetServer(name string) (*ServerConnection, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
conn, ok := m.servers[name]
return conn, ok
}
// CallTool calls a tool on a specific server
func (m *Manager) CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error) {
m.mu.RLock()
if m.closed {
m.mu.RUnlock()
return nil, fmt.Errorf("manager is closed")
}
conn, ok := m.servers[serverName]
if ok {
m.wg.Add(1)
}
m.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("server %s not found", serverName)
}
defer m.wg.Done()
params := &mcp.CallToolParams{
Name: toolName,
Arguments: arguments,
}
result, err := conn.Session.CallTool(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to call tool: %w", err)
}
return result, nil
}
// Close closes all server connections
func (m *Manager) Close() error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil
}
m.closed = true
m.mu.Unlock()
// Wait for all in-flight CallTool calls to finish before closing sessions
m.wg.Wait()
m.mu.Lock()
defer m.mu.Unlock()
logger.InfoCF("mcp", "Closing all MCP server connections",
map[string]any{
"count": len(m.servers),
})
var errs []error
for name, conn := range m.servers {
if err := conn.Session.Close(); err != nil {
logger.ErrorCF("mcp", "Failed to close server connection",
map[string]any{
"server": name,
"error": err.Error(),
})
errs = append(errs, fmt.Errorf("server %s: %w", name, err))
}
}
m.servers = make(map[string]*ServerConnection)
if len(errs) > 0 {
return fmt.Errorf("failed to close %d server(s): %w", len(errs), errors.Join(errs...))
}
return nil
}
// GetAllTools returns all tools from all connected servers
func (m *Manager) GetAllTools() map[string][]*mcp.Tool {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string][]*mcp.Tool)
for name, conn := range m.servers {
if len(conn.Tools) > 0 {
result[name] = conn.Tools
}
}
return result
}

298
pkg/mcp/manager_test.go Normal file
View file

@ -0,0 +1,298 @@
package mcp
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestLoadEnvFile(t *testing.T) {
tests := []struct {
name string
content string
expected map[string]string
expectErr bool
}{
{
name: "basic env file",
content: `API_KEY=secret123
DATABASE_URL=postgres://localhost/db
PORT=8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "with comments and empty lines",
content: `# This is a comment
API_KEY=secret123
# Another comment
DATABASE_URL=postgres://localhost/db
PORT=8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "with quoted values",
content: `API_KEY="secret with spaces"
NAME='single quoted'
PLAIN=no-quotes`,
expected: map[string]string{
"API_KEY": "secret with spaces",
"NAME": "single quoted",
"PLAIN": "no-quotes",
},
expectErr: false,
},
{
name: "with spaces around equals",
content: `API_KEY = secret123
DATABASE_URL= postgres://localhost/db
PORT =8080`,
expected: map[string]string{
"API_KEY": "secret123",
"DATABASE_URL": "postgres://localhost/db",
"PORT": "8080",
},
expectErr: false,
},
{
name: "invalid format - no equals",
content: `INVALID_LINE`,
expectErr: true,
},
{
name: "empty file",
content: ``,
expected: map[string]string{},
expectErr: false,
},
{
name: "only comments",
content: `# Comment 1
# Comment 2`,
expected: map[string]string{},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
if err := os.WriteFile(envFile, []byte(tt.content), 0o644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
result, err := loadEnvFile(envFile)
if tt.expectErr {
if err == nil {
t.Errorf("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(result) != len(tt.expected) {
t.Errorf("Expected %d variables, got %d", len(tt.expected), len(result))
}
for key, expectedValue := range tt.expected {
if actualValue, ok := result[key]; !ok {
t.Errorf("Expected key %s not found", key)
} else if actualValue != expectedValue {
t.Errorf("For key %s: expected %q, got %q", key, expectedValue, actualValue)
}
}
})
}
}
func TestLoadEnvFileNotFound(t *testing.T) {
_, err := loadEnvFile("/nonexistent/file.env")
if err == nil {
t.Error("Expected error for nonexistent file")
}
}
func TestEnvFilePriority(t *testing.T) {
// Create a temporary .env file
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
envContent := `API_KEY=from_file
DATABASE_URL=from_file
SHARED_VAR=from_file`
if err := os.WriteFile(envFile, []byte(envContent), 0o644); err != nil {
t.Fatalf("Failed to create .env file: %v", err)
}
// Load envFile
envVars, err := loadEnvFile(envFile)
if err != nil {
t.Fatalf("Failed to load env file: %v", err)
}
// Verify envFile variables
if envVars["API_KEY"] != "from_file" {
t.Errorf("Expected API_KEY=from_file, got %s", envVars["API_KEY"])
}
// Simulate config.Env overriding envFile
configEnv := map[string]string{
"SHARED_VAR": "from_config",
"NEW_VAR": "from_config",
}
// Merge: envFile first, then config overrides
merged := make(map[string]string)
for k, v := range envVars {
merged[k] = v
}
for k, v := range configEnv {
merged[k] = v
}
// Verify priority: config.Env should override envFile
if merged["SHARED_VAR"] != "from_config" {
t.Errorf(
"Expected SHARED_VAR=from_config (config should override file), got %s",
merged["SHARED_VAR"],
)
}
if merged["API_KEY"] != "from_file" {
t.Errorf("Expected API_KEY=from_file, got %s", merged["API_KEY"])
}
if merged["NEW_VAR"] != "from_config" {
t.Errorf("Expected NEW_VAR=from_config, got %s", merged["NEW_VAR"])
}
}
func TestLoadFromMCPConfig_EmptyWorkspaceWithRelativeEnvFile(t *testing.T) {
mgr := NewManager()
mcpCfg := config.MCPConfig{
Enabled: true,
Servers: map[string]config.MCPServerConfig{
"test-server": {
Enabled: true,
Command: "echo",
Args: []string{"ok"},
EnvFile: ".env",
},
},
}
err := mgr.LoadFromMCPConfig(context.Background(), mcpCfg, "")
if err == nil {
t.Fatal("expected error for relative env_file with empty workspace path, got nil")
}
if !strings.Contains(err.Error(), "workspace path is empty") {
t.Fatalf("expected workspace path validation error, got: %v", err)
}
}
func TestNewManager_InitialState(t *testing.T) {
mgr := NewManager()
if mgr == nil {
t.Fatal("expected manager instance, got nil")
}
if len(mgr.GetServers()) != 0 {
t.Fatalf("expected no servers on new manager, got %d", len(mgr.GetServers()))
}
}
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
mgr := NewManager()
err := mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: false}, "/tmp")
if err != nil {
t.Fatalf("expected nil error when MCP disabled, got: %v", err)
}
err = mgr.LoadFromMCPConfig(context.Background(), config.MCPConfig{Enabled: true}, "/tmp")
if err != nil {
t.Fatalf("expected nil error when no servers configured, got: %v", err)
}
}
func TestGetServers_ReturnsCopy(t *testing.T) {
mgr := NewManager()
mgr.servers["s1"] = &ServerConnection{Name: "s1"}
servers := mgr.GetServers()
delete(servers, "s1")
if _, ok := mgr.GetServer("s1"); !ok {
t.Fatal("expected internal manager state to remain unchanged")
}
}
func TestGetAllTools_FiltersEmptyTools(t *testing.T) {
mgr := NewManager()
mgr.servers["empty"] = &ServerConnection{Name: "empty", Tools: nil}
mgr.servers["with-tools"] = &ServerConnection{Name: "with-tools", Tools: []*sdkmcp.Tool{{}}}
all := mgr.GetAllTools()
if _, ok := all["empty"]; ok {
t.Fatal("expected server without tools to be excluded")
}
if _, ok := all["with-tools"]; !ok {
t.Fatal("expected server with tools to be included")
}
}
func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {
t.Run("manager closed", func(t *testing.T) {
mgr := NewManager()
mgr.closed = true
_, err := mgr.CallTool(context.Background(), "s1", "tool", nil)
if err == nil || !strings.Contains(err.Error(), "manager is closed") {
t.Fatalf("expected manager closed error, got: %v", err)
}
})
t.Run("server missing", func(t *testing.T) {
mgr := NewManager()
_, err := mgr.CallTool(context.Background(), "missing", "tool", nil)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected server not found error, got: %v", err)
}
})
}
func TestClose_IdempotentOnEmptyManager(t *testing.T) {
mgr := NewManager()
if err := mgr.Close(); err != nil {
t.Fatalf("first close should succeed, got: %v", err)
}
if err := mgr.Close(); err != nil {
t.Fatalf("second close should be idempotent, got: %v", err)
}
}

View file

@ -102,6 +102,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://openrouter.ai/api/v1" sel.apiBase = "https://openrouter.ai/api/v1"
} }
} }
case "litellm":
if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
sel.apiKey = cfg.Providers.LiteLLM.APIKey
sel.apiBase = cfg.Providers.LiteLLM.APIBase
sel.proxy = cfg.Providers.LiteLLM.Proxy
if sel.apiBase == "" {
sel.apiBase = "http://localhost:4000/v1"
}
}
case "zhipu", "glm": case "zhipu", "glm":
if cfg.Providers.Zhipu.APIKey != "" { if cfg.Providers.Zhipu.APIKey != "" {
sel.apiKey = cfg.Providers.Zhipu.APIKey sel.apiKey = cfg.Providers.Zhipu.APIKey

View file

@ -53,7 +53,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
// CreateProviderFromConfig creates a provider based on the ModelConfig. // CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create. // It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot // Supported protocols: openai, litellm, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Returns the provider, the model ID (without protocol prefix), and any error. // Returns the provider, the model ID (without protocol prefix), and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil { if cfg == nil {
@ -92,7 +92,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, nil ), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"volcengine", "vllm", "qwen", "mistral": "volcengine", "vllm", "qwen", "mistral":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
@ -180,6 +180,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.openai.com/v1" return "https://api.openai.com/v1"
case "openrouter": case "openrouter":
return "https://openrouter.ai/api/v1" return "https://openrouter.ai/api/v1"
case "litellm":
return "http://localhost:4000/v1"
case "groq": case "groq":
return "https://api.groq.com/openai/v1" return "https://api.groq.com/openai/v1"
case "zhipu": case "zhipu":

View file

@ -135,6 +135,32 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
} }
} }
func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
if got := getDefaultAPIBase("litellm"); got != "http://localhost:4000/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "litellm", got, "http://localhost:4000/v1")
}
}
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
Model: "litellm/my-proxy-alias",
APIKey: "test-key",
APIBase: "http://localhost:4000/v1",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "my-proxy-alias" {
t.Errorf("modelID = %q, want %q", modelID, "my-proxy-alias")
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) { func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-anthropic", ModelName: "test-anthropic",

View file

@ -17,6 +17,27 @@ func TestResolveProviderSelection(t *testing.T) {
wantProxy string wantProxy string
wantErrSubstr string wantErrSubstr string
}{ }{
{
name: "explicit litellm provider uses configured base",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
wantProxy: "http://127.0.0.1:7890",
},
{
name: "explicit litellm provider defaults base when only key is configured",
setup: func(cfg *config.Config) {
cfg.Agents.Defaults.Provider = "litellm"
cfg.Providers.LiteLLM.APIKey = "litellm-key"
},
wantType: providerTypeHTTPCompat,
wantAPIBase: "http://localhost:4000/v1",
},
{ {
name: "explicit claude-cli provider routes to cli provider type", name: "explicit claude-cli provider routes to cli provider type",
setup: func(cfg *config.Config) { setup: func(cfg *config.Config) {

View file

@ -325,7 +325,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before) prefix := strings.ToLower(before)
switch prefix { switch prefix {
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return after return after
default: default:
return model return model

View file

@ -256,6 +256,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
input string input string
wantModel string wantModel string
}{ }{
{
name: "strips litellm prefix and preserves proxy model name",
input: "litellm/my-proxy-alias",
wantModel: "my-proxy-alias",
},
{ {
name: "strips groq prefix and keeps nested model", name: "strips groq prefix and keeps nested model",
input: "groq/openai/gpt-oss-120b", input: "groq/openai/gpt-oss-120b",

246
pkg/tools/mcp_tool.go Normal file
View file

@ -0,0 +1,246 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPManager defines the interface for MCP manager operations
// This allows for easier testing with mock implementations
type MCPManager interface {
CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error)
}
// MCPTool wraps an MCP tool to implement the Tool interface
type MCPTool struct {
manager MCPManager
serverName string
tool *mcp.Tool
}
// NewMCPTool creates a new MCP tool wrapper
func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool {
return &MCPTool{
manager: manager,
serverName: serverName,
tool: tool,
}
}
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
// - lowercases the string
// - replaces any character not in [a-z0-9_-] with '_'
// - collapses multiple consecutive '_' into a single '_'
// - trims leading/trailing '_'
// - falls back to "unnamed" if the result is empty
// - truncates overly long components to a reasonable length
func sanitizeIdentifierComponent(s string) string {
const maxLen = 64
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
prevUnderscore := false
for _, r := range s {
isAllowed := (r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9') ||
r == '_' || r == '-'
if !isAllowed {
// Normalize any disallowed character to '_'
if !prevUnderscore {
b.WriteRune('_')
prevUnderscore = true
}
continue
}
if r == '_' {
if prevUnderscore {
continue
}
prevUnderscore = true
} else {
prevUnderscore = false
}
b.WriteRune(r)
}
result := strings.Trim(b.String(), "_")
if result == "" {
result = "unnamed"
}
if len(result) > maxLen {
result = result[:maxLen]
}
return result
}
// Name returns the tool name, prefixed with the server name.
// The total length is capped at 64 characters (OpenAI-compatible API limit).
// A short hash of the original (unsanitized) server and tool names is appended
// whenever sanitization is lossy or the name is truncated, ensuring that two
// names which differ only in disallowed characters remain distinct after sanitization.
func (t *MCPTool) Name() string {
// Prefix with server name to avoid conflicts, and sanitize components
sanitizedServer := sanitizeIdentifierComponent(t.serverName)
sanitizedTool := sanitizeIdentifierComponent(t.tool.Name)
full := fmt.Sprintf("mcp_%s_%s", sanitizedServer, sanitizedTool)
// Check if sanitization was lossless (only lowercasing, no char replacement/truncation)
lossless := strings.ToLower(t.serverName) == sanitizedServer &&
strings.ToLower(t.tool.Name) == sanitizedTool
const maxTotal = 64
if lossless && len(full) <= maxTotal {
return full
}
// Sanitization was lossy or name too long: append hash of the ORIGINAL names
// (not the sanitized names) so different originals always yield different hashes.
h := fnv.New32a()
_, _ = h.Write([]byte(t.serverName + "\x00" + t.tool.Name))
suffix := fmt.Sprintf("%08x", h.Sum32()) // 8 chars
base := full
if len(base) > maxTotal-9 {
base = strings.TrimRight(full[:maxTotal-9], "_")
}
return base + "_" + suffix
}
// Description returns the tool description
func (t *MCPTool) Description() string {
desc := t.tool.Description
if desc == "" {
desc = fmt.Sprintf("MCP tool from %s server", t.serverName)
}
// Add server info to description
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
}
// Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object
schema := t.tool.InputSchema
// Handle nil schema
if schema == nil {
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
// Try direct conversion first (fast path)
if schemaMap, ok := schema.(map[string]any); ok {
return schemaMap
}
// Handle json.RawMessage and []byte - unmarshal directly
var jsonData []byte
if rawMsg, ok := schema.(json.RawMessage); ok {
jsonData = rawMsg
} else if bytes, ok := schema.([]byte); ok {
jsonData = bytes
}
if jsonData != nil {
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err == nil {
return result
}
// Fallback on error
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
// For other types (structs, etc.), convert via JSON marshal/unmarshal
var err error
jsonData, err = json.Marshal(schema)
if err != nil {
// Fallback to empty schema if marshaling fails
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err != nil {
// Fallback to empty schema if unmarshaling fails
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
}
}
return result
}
// Execute executes the MCP tool
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
if err != nil {
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
}
if result == nil {
nilErr := fmt.Errorf("MCP tool returned nil result without error")
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
}
// Handle error result from server
if result.IsError {
errMsg := extractContentText(result.Content)
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
// Extract text content from result
output := extractContentText(result.Content)
return &ToolResult{
ForLLM: output,
IsError: false,
}
}
// extractContentText extracts text from MCP content array
func extractContentText(content []mcp.Content) string {
var parts []string
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
parts = append(parts, v.Text)
case *mcp.ImageContent:
// For images, just indicate that an image was returned
parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType))
default:
// For other content types, use string representation
parts = append(parts, fmt.Sprintf("[Content: %T]", v))
}
}
return strings.Join(parts, "\n")
}

492
pkg/tools/mcp_tool_test.go Normal file
View file

@ -0,0 +1,492 @@
package tools
import (
"context"
"fmt"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
type MockMCPManager struct {
callToolFunc func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error)
}
func (m *MockMCPManager) CallTool(
ctx context.Context,
serverName, toolName string,
arguments map[string]any,
) (*mcp.CallToolResult, error) {
if m.callToolFunc != nil {
return m.callToolFunc(ctx, serverName, toolName, arguments)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "mock result"},
},
IsError: false,
}, nil
}
// TestNewMCPTool verifies MCP tool creation
func TestNewMCPTool(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
Description: "A test tool",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{
"type": "string",
"description": "Test input",
},
},
},
}
mcpTool := NewMCPTool(manager, "test_server", tool)
if mcpTool == nil {
t.Fatal("NewMCPTool should not return nil")
}
// Verify tool properties we can access
if mcpTool.Name() != "mcp_test_server_test_tool" {
t.Errorf("Expected tool name with prefix, got '%s'", mcpTool.Name())
}
}
// TestMCPTool_Name verifies tool name with server prefix
func TestMCPTool_Name(t *testing.T) {
tests := []struct {
name string
serverName string
toolName string
expected string
}{
{
name: "simple name",
serverName: "github",
toolName: "create_issue",
expected: "mcp_github_create_issue",
},
{
name: "filesystem server",
serverName: "filesystem",
toolName: "read_file",
expected: "mcp_filesystem_read_file",
},
{
name: "remote server",
serverName: "remote-api",
toolName: "fetch_data",
expected: "mcp_remote-api_fetch_data",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{Name: tt.toolName}
mcpTool := NewMCPTool(manager, tt.serverName, tool)
result := mcpTool.Name()
if result != tt.expected {
t.Errorf("Expected name '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) {
tests := []struct {
name string
serverName string
toolDescription string
expectContains []string
}{
{
name: "with description",
serverName: "github",
toolDescription: "Create a GitHub issue",
expectContains: []string{"[MCP:github]", "Create a GitHub issue"},
},
{
name: "empty description",
serverName: "filesystem",
toolDescription: "",
expectContains: []string{"[MCP:filesystem]", "MCP tool from filesystem server"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
Description: tt.toolDescription,
}
mcpTool := NewMCPTool(manager, tt.serverName, tool)
result := mcpTool.Description()
for _, expected := range tt.expectContains {
if !strings.Contains(result, expected) {
t.Errorf("Description should contain '%s', got: %s", expected, result)
}
}
})
}
}
// TestMCPTool_Parameters verifies parameter schema conversion
func TestMCPTool_Parameters(t *testing.T) {
tests := []struct {
name string
inputSchema any
expectType string
checkProperty string
expectProperty bool
}{
{
name: "map schema",
inputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Search query",
},
},
"required": []string{"query"},
},
expectType: "object",
checkProperty: "query",
expectProperty: true,
},
{
name: "nil schema",
inputSchema: nil,
expectType: "object",
expectProperty: false,
},
{
name: "json.RawMessage schema",
inputSchema: []byte(`{
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "Repository name"
},
"stars": {
"type": "integer",
"description": "Minimum stars"
}
},
"required": ["repo"]
}`),
expectType: "object",
checkProperty: "repo",
expectProperty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{
Name: "test_tool",
InputSchema: tt.inputSchema,
}
mcpTool := NewMCPTool(manager, "test_server", tool)
params := mcpTool.Parameters()
if params == nil {
t.Fatal("Parameters should not be nil")
}
if params["type"] != tt.expectType {
t.Errorf("Expected type '%s', got '%v'", tt.expectType, params["type"])
}
// Check if property exists when expected
if tt.checkProperty != "" {
properties, ok := params["properties"].(map[string]any)
if !ok && tt.expectProperty {
t.Errorf("Expected properties to be a map")
return
}
if ok {
_, hasProperty := properties[tt.checkProperty]
if hasProperty != tt.expectProperty {
t.Errorf("Expected property '%s' existence: %v, got: %v",
tt.checkProperty, tt.expectProperty, hasProperty)
}
}
}
})
}
}
// TestMCPTool_Execute_Success tests successful tool execution
func TestMCPTool_Execute_Success(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
// Verify correct parameters passed
if serverName != "github" {
t.Errorf("Expected serverName 'github', got '%s'", serverName)
}
if toolName != "search_repos" {
t.Errorf("Expected toolName 'search_repos', got '%s'", toolName)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Found 3 repositories"},
},
IsError: false,
}, nil
},
}
tool := &mcp.Tool{
Name: "search_repos",
Description: "Search GitHub repositories",
}
mcpTool := NewMCPTool(manager, "github", tool)
ctx := context.Background()
args := map[string]any{
"query": "golang mcp",
}
result := mcpTool.Execute(ctx, args)
if result == nil {
t.Fatal("Result should not be nil")
}
if result.IsError {
t.Errorf("Expected no error, got error: %s", result.ForLLM)
}
if result.ForLLM != "Found 3 repositories" {
t.Errorf("Expected 'Found 3 repositories', got '%s'", result.ForLLM)
}
}
// TestMCPTool_Execute_ManagerError tests execution when manager returns error
func TestMCPTool_Execute_ManagerError(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return nil, fmt.Errorf("connection failed")
},
}
tool := &mcp.Tool{Name: "test_tool"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result == nil {
t.Fatal("Result should not be nil")
}
if !result.IsError {
t.Error("Expected IsError to be true")
}
if !strings.Contains(result.ForLLM, "MCP tool execution failed") {
t.Errorf("Error message should mention execution failure, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "connection failed") {
t.Errorf("Error message should include original error, got: %s", result.ForLLM)
}
}
// TestMCPTool_Execute_ServerError tests execution when server returns error
func TestMCPTool_Execute_ServerError(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Invalid API key"},
},
IsError: true,
}, nil
},
}
tool := &mcp.Tool{Name: "test_tool"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result == nil {
t.Fatal("Result should not be nil")
}
if !result.IsError {
t.Error("Expected IsError to be true")
}
if !strings.Contains(result.ForLLM, "MCP tool returned error") {
t.Errorf("Error message should mention server error, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Invalid API key") {
t.Errorf("Error message should include server message, got: %s", result.ForLLM)
}
}
// TestMCPTool_Execute_MultipleContent tests execution with multiple content items
func TestMCPTool_Execute_MultipleContent(t *testing.T) {
manager := &MockMCPManager{
callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "First line"},
&mcp.TextContent{Text: "Second line"},
&mcp.TextContent{Text: "Third line"},
},
IsError: false,
}, nil
},
}
tool := &mcp.Tool{Name: "multi_output"}
mcpTool := NewMCPTool(manager, "test_server", tool)
ctx := context.Background()
result := mcpTool.Execute(ctx, map[string]any{})
if result.IsError {
t.Errorf("Expected no error, got: %s", result.ForLLM)
}
expected := "First line\nSecond line\nThird line"
if result.ForLLM != expected {
t.Errorf("Expected '%s', got '%s'", expected, result.ForLLM)
}
}
// TestExtractContentText_TextContent tests text content extraction
func TestExtractContentText_TextContent(t *testing.T) {
content := []mcp.Content{
&mcp.TextContent{Text: "Hello World"},
&mcp.TextContent{Text: "Second message"},
}
result := extractContentText(content)
expected := "Hello World\nSecond message"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
// TestExtractContentText_ImageContent tests image content extraction
func TestExtractContentText_ImageContent(t *testing.T) {
content := []mcp.Content{
&mcp.ImageContent{
Data: []byte("base64data"),
MIMEType: "image/png",
},
}
result := extractContentText(content)
if !strings.Contains(result, "[Image:") {
t.Errorf("Expected image indicator, got: %s", result)
}
if !strings.Contains(result, "image/png") {
t.Errorf("Expected MIME type in output, got: %s", result)
}
}
// TestExtractContentText_MixedContent tests mixed content types
func TestExtractContentText_MixedContent(t *testing.T) {
content := []mcp.Content{
&mcp.TextContent{Text: "Description"},
&mcp.ImageContent{
Data: []byte("data"),
MIMEType: "image/jpeg",
},
&mcp.TextContent{Text: "More text"},
}
result := extractContentText(content)
if !strings.Contains(result, "Description") {
t.Errorf("Should contain text content, got: %s", result)
}
if !strings.Contains(result, "[Image:") {
t.Errorf("Should contain image indicator, got: %s", result)
}
if !strings.Contains(result, "More text") {
t.Errorf("Should contain second text, got: %s", result)
}
}
// TestExtractContentText_EmptyContent tests empty content array
func TestExtractContentText_EmptyContent(t *testing.T) {
content := []mcp.Content{}
result := extractContentText(content)
if result != "" {
t.Errorf("Expected empty string for empty content, got: %s", result)
}
}
// TestMCPTool_InterfaceCompliance verifies MCPTool implements Tool interface
func TestMCPTool_InterfaceCompliance(t *testing.T) {
manager := &MockMCPManager{}
tool := &mcp.Tool{Name: "test"}
mcpTool := NewMCPTool(manager, "test_server", tool)
// Verify it implements Tool interface
var _ Tool = mcpTool
}
// TestMCPTool_Parameters_MapSchema tests schema that's already a map
func TestMCPTool_Parameters_MapSchema(t *testing.T) {
manager := &MockMCPManager{}
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "The name parameter",
},
},
"required": []string{"name"},
}
tool := &mcp.Tool{
Name: "test_tool",
InputSchema: schema,
}
mcpTool := NewMCPTool(manager, "test_server", tool)
params := mcpTool.Parameters()
// Should return the schema as-is when it's already a map
if params["type"] != "object" {
t.Errorf("Expected type 'object', got '%v'", params["type"])
}
props, ok := params["properties"].(map[string]any)
if !ok {
t.Error("Properties should be a map")
}
nameParam, ok := props["name"].(map[string]any)
if !ok {
t.Error("Name parameter should exist")
}
if nameParam["type"] != "string" {
t.Errorf("Name type should be 'string', got '%v'", nameParam["type"])
}
}

View file

@ -25,7 +25,12 @@ func NewToolRegistry() *ToolRegistry {
func (r *ToolRegistry) Register(tool Tool) { func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.tools[tool.Name()] = tool name := tool.Name()
if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Tool registration overwrites existing tool",
map[string]any{"name": name})
}
r.tools[name] = tool
} }
func (r *ToolRegistry) Get(name string) (Tool, bool) { func (r *ToolRegistry) Get(name string) (Tool, bool) {

49
scripts/test-docker-mcp.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/sh
# Test script for MCP tools in Docker (full-featured image)
set -e
COMPOSE_FILE="docker/docker-compose.full.yml"
SERVICE="picoclaw-agent"
echo "🧪 Testing MCP tools in Docker container (full-featured image)..."
echo ""
# Build the image
echo "📦 Building Docker image..."
docker compose -f "$COMPOSE_FILE" build "$SERVICE"
# Test npx
echo "✅ Testing npx..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npx --version'
# Test npm
echo "✅ Testing npm..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'npm --version'
# Test node
echo "✅ Testing Node.js..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'node --version'
# Test git
echo "✅ Testing git..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'git --version'
# Test python
echo "✅ Testing Python..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'python3 --version'
# Test uv
echo "✅ Testing uv..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c 'uv --version'
# Test MCP server installation (quick)
echo "✅ Testing @modelcontextprotocol/server-filesystem MCP server install with npx..."
docker compose -f "$COMPOSE_FILE" run --rm --entrypoint sh "$SERVICE" -c '</dev/null timeout 5 npx -y @modelcontextprotocol/server-filesystem /tmp || true'
echo ""
echo "🎉 All MCP tools are working correctly!"
echo ""
echo "Next steps:"
echo " 1. Configure MCP servers in config/config.json"
echo " 2. Run: docker compose -f $COMPOSE_FILE --profile gateway up"