diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..d5eb0ea5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,136 @@
+# ============================================
+# 白名单模式 .gitignore 配置
+# 策略:先忽略所有,再逐个放行需要的文件
+# ============================================
+
+# 第一步:忽略所有文件和目录
+*
+
+# ============================================
+# 第二步:放行根目录的重要文件
+# ============================================
+# README 文档
+!README.md
+!README_zh.md
+!README_en.md
+
+# 许可和发布文件
+!LICENSE
+!PUBLISH.md
+!MANIFEST.in
+
+# Python 项目配置文件
+!pyproject.toml
+!pytest.ini
+
+# Docker 配置
+!docker-compose.yml
+!.dockerignore
+
+# 环境配置示例
+!.env.example
+
+# 包管理器锁文件
+!uv.lock
+
+# ============================================
+# 第三步:放行重要目录及其内容
+# ============================================
+
+# --- src/ 目录(核心代码) ---
+!src/
+!src/**
+
+# --- wiki/ 目录(文档) ---
+!wiki/
+!wiki/**
+
+# --- vue/ 目录(前端项目) ---
+!vue/
+!vue/**
+# Vue 项目需要忽略的内容
+vue/node_modules/
+vue/dist/
+vue/.nuxt/
+vue/.output/
+vue/.venv/
+
+# --- docs/ 目录(文档) ---
+!docs/
+!docs/**
+
+# --- docker/ 目录(Docker 配置) ---
+!docker/
+!docker/**
+
+# --- example/ 目录(示例代码) ---
+!example/
+!example/**
+
+# --- tests/ 目录(测试文件) ---
+!tests/
+!tests/**
+
+# ============================================
+# 第四步:忽略不需要的内容
+# ============================================
+
+# Python 相关
+**/__pycache__/
+**/*.pyc
+**/*.pyo
+**/*.pyd
+**/.Python
+**/pip-log.txt
+**/pip-delete-this-directory.txt
+
+# 虚拟环境
+.venv/
+venv/
+ENV/
+env/
+
+# 测试覆盖率
+.coverage
+.coverage.*
+htmlcov/
+.tox/
+.pytest_cache/
+.hypothesis/
+
+# 构建产物
+dist/
+build/
+*.egg-info/
+*.egg
+
+# IDE 配置文件
+.idea/
+.vscode/
+.cursor/
+.codex/
+*.swp
+*.swo
+*~
+
+# 日志和临时文件
+*.log
+logs/
+temp/
+uploads/
+**/.DS_Store
+
+# MCP 和其他工具配置
+.mcpstore/
+.serena/
+.specstory/
+.spec-workflow/
+.claude/
+.kiro/
+.home/
+
+# 其他
+.git/
+*.bak
+vulture_report.json
+.workflow-confirmations.json
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..5b623809
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 ooooofish
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 00000000..4bb87480
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,23 @@
+include LICENSE
+include README.md
+recursive-include src/mcpstore *.py
+recursive-include src/mcpstore *.json
+recursive-include src/mcpstore *.md
+recursive-include src/mcpstore *.txt
+recursive-include src/mcpstore *.yaml
+recursive-include src/mcpstore *.yml
+
+# 排除测试文件和临时文件
+global-exclude test_*.py
+global-exclude *_test.py
+global-exclude tests/*
+global-exclude __pycache__/*
+global-exclude *.pyc
+global-exclude *.pyo
+global-exclude *.egg-info/*
+global-exclude .pytest_cache/*
+global-exclude .coverage
+global-exclude coverage.xml
+global-exclude *.log
+global-exclude 临时*global-exclude 测试*
+global-exclude 非src测试*
diff --git a/README.md b/README.md
index c9c50695..8344071c 100644
--- a/README.md
+++ b/README.md
@@ -1,332 +1,283 @@
-# MCPStore
-MCPStore 是一个强大的 MCP(Model Context Protocol)工具管理库。对于许多的 agent 或者 chain 来说,我们想要使用 MCP 的 tool,但是使用 MCP 的配置和管理有些复杂。针对这个情况,我开发了 MCPStore。对于智能体来说,我们相当于创建了一个 store,agent 可以挑选他需要的 MCP 服务。我的目的是让现有的 agent 开发项目可以无感添加 tool,只需要几行代码的配置,就可以在原来的代码上添加这些工具。
-## 特性
+
-- 🚀 简单集成:仅需几行代码即可完成工具调用
-- 🔄 链式操作:直观的 API 设计,支持流畅的链式调用
-- 🎯 精确控制:支持全局 Store 模式和独立 Agent 模式
-- 🔒 隔离管理:不同 Agent 之间的服务和工具完全隔离
-- 📦 配置集中:统一的配置管理,支持动态服务注册
+
-## 快速开始
+---
-### 安装
+   
-```bash
-pip install mcpstore
-```
-### 基础使用
-只需三行代码即可实现工具调用。支持多种方式:
+[English](README_en.md) | [简体中文](README_zh.md)
-1. 通过配置文件注册:
-```python
-# 1. 创建 Store 实例
-from mcpstore import MCPStore
-store = MCPStore.setup_store()
+[在线体验](https://web.mcpstore.wiki) | [详细文档](https://doc.mcpstore.wiki/) | [快速使用](###简单示例)
-# 2. 注册配置文件中的服务
-reg_result = await store.for_store().add_service()
+
-# 3. 使用工具
-result = await store.for_store().use_tool(
- "高德_maps_direction_driving",
- {
- "origin": "116.481028,39.989643",
- "destination": "116.434446,39.90816"
- }
-)
-```
+### mcpstore 是什么?
+
+mcpstore 是面向开发者的开箱即用的 MCP 服务编排层:用一个 Store 统一管理服务,并将 MCP 适配给 AI 框架`LangChain等`使用。
+
+### 简单示例
+
+首先只需要需要初始化一个store
-2. 直接配置方式:
```python
-# 1. 创建 Store 实例
from mcpstore import MCPStore
store = MCPStore.setup_store()
-
-# 2. 直接添加服务配置
-reg_result = await store.for_store().add_service({
- "name": "高德",
- "url": "https://mcp.amap.com/sse?key=your_key",
- "transport": "sse"
-})
-
-# 3. 使用工具
-result = await store.for_store().use_tool(
- "高德_maps_direction_driving",
- {
- "origin": "116.481028,39.989643",
- "destination": "116.434446,39.90816"
- }
-)
```
-### 服务注册方式
+现在就有了一个 `store`,后续只需要围绕这个`store`去添加或者操作你的服务,`store` 会维护和管理这些 MCP 服务。
-MCPStore 提供了灵活的服务注册机制,通过 `add_service` 方法支持多种注册方式:
+#### 给store添加第一个服务
-#### 1. 配置文件注册
-从 mcp.json 注册所有服务:
```python
-await store.for_store().add_service()
+#在上面的代码下面加入
+store.for_store().add_service({"mcpServers": {"mcpstore_wiki": {"url": "https://www.mcpstore.wiki/mcp"}}})
+store.for_store().wait_service("mcpstore_wiki")
```
-#### 2. 服务名称注册
-指定服务名称进行注册(适用于 Agent 模式):
+通过add方法便捷添加服务,add_service方法支持多种mcp服务配置格式,主流的mcp配置格式都可以直接传入。wait方法可选,是否同步等待服务就绪。
+
+#### 将mcp适配转为langchain需要的对象
+
```python
-await store.for_agent("agent_id").add_service(['高德', 'context7'])
+tools = store.for_store().for_langchain().list_tools()
+print("loaded langchain tools:", len(tools))
```
-#### 3. HTTP/SSE 服务配置
-直接添加 HTTP 或 SSE 类型的服务:
+简单链上即可直观的将mcp适配为langchain直接使用的tools列表
+
+##### 框架适配
+
+会逐渐支持更多的框架
+
+| 已支持框架 | 获取工具 |
+| --- | --- |
+| LangChain | `tools = store.for_store().for_langchain().list_tools()` |
+| LangGraph | `tools = store.for_store().for_langgraph().list_tools()` |
+| AutoGen | `tools = store.for_store().for_autogen().list_tools()` |
+| CrewAI | `tools = store.for_store().for_crewai().list_tools()` |
+| LlamaIndex | `tools = store.for_store().for_llamaindex().list_tools()` |
+
+#### 现在就可以正常的使用langchain了
+
```python
-await store.for_store().add_service({
- "name": "高德",
- "url": "https://mcp.amap.com/sse?key=your_key",
- "transport": "sse",
- "headers": { # 可选
- "Authorization": "Bearer token"
- }
-})
+#添加上面的代码
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+llm = ChatOpenAI(
+ temperature=0,
+ model="deepseek-chat",
+ api_key="sk-*****",
+ base_url="https://api.deepseek.com"
+)
+agent = create_agent(model=llm, tools=tools, system_prompt="你是一个助手,回答的时候带上表情")
+events = agent.invoke({"messages": [{"role": "user", "content": "mcpstore怎么添加服务?"}]})
+print(events)
```
-#### 4. 本地命令服务配置
-添加基于本地命令的服务:
-```python
-await store.for_store().add_service({
- "name": "local_service",
- "command": "python",
- "args": ["service.py"],
- "env": {"DEBUG": "true"},
- "working_dir": "/path/to/service" # 可选
-})
+### 快速开始
+
+```bash
+pip install mcpstore
```
-#### 5. NPX 工具服务配置
-添加基于 NPX 的工具服务:
+#### Agent 分组
+
+使用 `for_agent(agent_id)` 实现对mcp服务进行分组
+
```python
-await store.for_store().add_service({
- "name": "context7",
- "command": "npx",
- "args": ["-y", "@upstash/context7-mcp"]
-})
+agent_id1 = "agent1"
+store.for_agent(agent_id1).add_service({"name": "mcpstore_wiki", "url": "https://www.mcpstore.wiki/mcp"})
+
+agent_id2 = "agent2"
+store.for_agent(agent_id2).add_service({"name": "playwright", "command": "npx", "args": ["@playwright/mcp"]})
+
+agent1_tools = store.for_agent(agent_id1).list_tools()
+agent2_tools = store.for_agent(agent_id2).list_tools()
```
-#### 配置同步机制
+`store.for_agent(agent_id)` 与 `store.for_store()` 共享大部分函数接口,本质上是通过分组机制在全局范围内创建了一个逻辑子集。
-- 所有通过 `add_service` 添加的服务配置都会自动同步到 mcp.json 文件
-- Store 模式下添加的服务对所有 Agent 可见
-- Agent 模式下添加的服务会:
- - 更新到 mcp.json(如果是新服务)
- - 在 agent_clients.json 中创建 Agent-Client 映射
- - 在 client_services.json 中添加客户端配置
+通过为不同 Agent 分配专属服务实现服务的有效隔离,避免上下文过长。
-#### 最佳实践
+与聚合服务`hub_service`(实验性)和快速生成 A2A Agent Card (计划支持)配合较好。
-1. Store 模式使用建议:
- - 全局服务优先使用配置文件注册
- - 动态服务使用直接配置方式添加
-2. Agent 模式使用建议:
- - 已有服务使用服务名称列表注册
- - 特定服务使用直接配置方式添加
- - 注意服务隔离,避免相互影响
-3. 配置管理:
- - 定期检查配置文件同步状态
- - 重要配置变更前备份配置文件
- - 使用健康检查确保服务可用
+#### 常用操作
-## 使用场景
+| 动作 | 命令示例 |
+|-------------|----------------------------------------------------------------------------------------|
+| 定位服务 | `store.for_store().find_service("service_name")` |
+| 更新服务 | `store.for_store().update_service("service_name", new_config)` |
+| 增量更新 | `store.for_store().patch_service("service_name", {"headers": {"X-API-Key": "..."}})` |
+| 删除服务 | `store.for_store().delete_service("service_name")` |
+| 重启服务 | `store.for_store().restart_service("service_name")` |
+| 断开服务 | `store.for_store().disconnect_service("service_name")` |
+| 健康检查 | `store.for_store().check_services()` |
+| 查看配置 | `store.for_store().show_config()` |
+| 服务详情 | `store.for_store().get_service_info("service_name")` |
+| 等待就绪 | `store.for_store().wait_service("service_name", timeout=30)` |
+| 聚合服务 | `store.for_agent(agent_id).hub_services()` |
+| 列出Agent | `store.for_store().list_agents()` |
+| 列出服务 | `store.for_store().list_services()` |
+| 列出工具 | `store.for_store().list_tools()` |
+| 定位工具 | `store.for_store().find_tool("tool_name")` |
+| 执行工具 | `store.for_store().call_tool("tool_name", {"k": "v"})` |
-我采用直观的方法来设计 store,当你执行 `store = MCPStore.setup_store()` 之后你就拥有了一个 store,此时你可以围绕 store 进行各种操作。
+#### 缓存/Redis 后端
-### Store 模式(全局工具管理)
+支持使用 Redis 作为共享缓存后端,用于跨进程/多实例共享服务与工具元数据。安装额外依赖:
-Store 模式下,你可以进行链式操作,代码示例:
+```bash
+pip install mcpstore[redis]
+#或直接 单独 pip install redis
+```
-```python
-# 初始化 store
-store = MCPStore.setup_store()
+使用方式:在store初始化的时候通过 `external_db` 参数传入:
-print('=== 1. 链式store操作 ===')
-# 注册(全量)
-reg_result = await store.for_store().add_service()
-print('[链式store] 注册结果:', reg_result)
-
-# 列出服务
-services = await store.for_store().list_services()
-print('[链式store] 服务列表:', services)
-
-# 列出工具
-tools = await store.for_store().list_tools()
-print('[链式store] 工具列表:', tools)
-
-# 健康检查
-health = await store.for_store().check_services()
-print('[链式store] 健康检查:', health)
-
-# 展示单个服务详情
-if services:
- detail = await store.get_service_info(services[0].name)
- print(f'[链式store] 服务详情:', detail)
-
-# 使用工具示例
-result = await store.for_store().use_tool(
- "高德_maps_direction_driving",
- {
- "origin": "116.481028,39.989643",
- "destination": "116.434446,39.90816"
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store(
+ external_db={
+ "cache": {
+ "type": "redis",
+ "url": "redis://localhost:6379/0",
+ "password": None,
+ "namespace": "demo_namespace"
+ }
}
)
-print('[链式store] 驾车导航结果:', result)
```
+更多的`setup_store`配置见文档
-### Agent 模式(独立工具管理)
+### API 模式
-对于 agent 来说,如果你不希望 agent 添加所有的 MCP 工具,你希望你的 agent 可以是某一个行业的专家,你只需要指定一个 id,或者自动创建一个 id,然后你就可以对这个 agent 进行隔离的服务调用和执行。示例:
+#### 启动api
+通过SDK快速启动
```python
-print('\n=== 2. 链式agent操作 ===')
-agent_id = 'agent123'
-
-# 注册指定服务
-reg_result = await store.for_agent(agent_id).add_service(['高德'])
-print('[链式agent] 注册结果:', reg_result)
-
-# 列出服务
-agent_services = await store.for_agent(agent_id).list_services()
-print('[链式agent] 服务列表:', agent_services)
-
-# 列出工具
-agent_tools = await store.for_agent(agent_id).list_tools()
-print('[链式agent] 工具列表:', agent_tools)
-
-# 健康检查
-agent_health = await store.for_agent(agent_id).check_services()
-print('[链式agent] 健康检查:', agent_health)
-
-# 展示单个服务详情
-if agent_services:
- detail = await store.get_service_info(agent_services[0].name)
- print(f'[链式agent] 服务详情:', detail)
-
-# Agent工具调用示例
-agent_result = await store.for_agent(agent_id).use_tool(
- "高德_maps_direction_walking",
- {
- "origin": "116.481028,39.989643",
- "destination": "116.434446,39.90816"
- }
-)
-print('[链式agent] 步行导航结果:', agent_result)
+from mcpstore import MCPStore
+prod_store = MCPStore.setup_store()
+prod_store.start_api_server(host="0.0.0.0", port=18200)
+```
+
+或者使用CLI快速启动
+```bash
+mcpstore run api
```
+
-## 架构设计
+示例页面:[在线体验](https://web.mcpstore.wiki)
-MCPStore 采用分层架构设计:
+#### 常用接口
+
+```bash
+# 服务管理
+POST /for_store/add_service
+GET /for_store/list_services
+POST /for_store/delete_service
+
+# 工具操作
+GET /for_store/list_tools
+POST /for_store/use_tool
+
+# 运行状态
+GET /for_store/get_stats
+GET /for_store/health
```
-MCPStore
-├── Store 层:全局工具和服务管理
-├── Agent 层:独立的工具和服务管理
-├── 配置层:统一的配置管理
-└── 执行层:工具调用和结果处理
+更多见接口文档: [详细文档](https://doc.mcpstore.wiki/)
+
+### Web 界面
+
+mcpstore 提供了基于 Vue.js 的可视化管理界面,可以通过浏览器方便地管理 MCP 服务、查看工具列表、执行工具调用等操作。
+
+#### 使用 Docker 启动(推荐)
+
+最简单的方式是使用 Docker Compose 启动完整的服务栈(包括 API 后端和 Web 前端):
+
+```bash
+# 启动所有服务(API + Web + 文档 + Wiki)
+cd docker
+./start-all.sh
+
+# 或单独启动 Web 和 API 服务
+cd docker/web && docker-compose up -d
+cd docker/api && docker-compose up -d
```
-### 配置文件
+启动后访问:
+- **Web 界面**: http://localhost:5177
+- **API 服务**: http://localhost:18200
+
+#### 本地开发模式
+
+如果需要在本地开发环境运行 Web 界面:
+
+**1. 启动 API 后端**
-所有配置文件统一存放在 `data/defaults` 目录下:
-- `mcp.json`: MCP 服务配置
-- `client_services.json`: 客户端服务配置
-- `agent_clients.json`: Agent-Client 映射配置
+```bash
+# 方式一:使用 CLI
+mcpstore run api
+
+# 方式二:使用 Python
+python -c "from mcpstore import MCPStore; store = MCPStore.setup_store(); store.start_api_server(host='0.0.0.0', port=18200)"
+```
+
+**2. 启动 Web 前端**
-## API 参考
+```bash
+cd vue
-### Store API
+# 安装依赖(首次运行)
+npm install
-- `for_store()`: 进入 Store 上下文
-- `add_service()`: 注册服务
-- `list_services()`: 列出服务
-- `list_tools()`: 列出工具
-- `check_services()`: 健康检查
-- `use_tool()`: 调用工具
+# 启动开发服务器
+npm run dev
-### Agent API
+# 或指定主机模式
+npm run dev:local # 仅本机访问
+npm run dev:domain # 允许局域网访问
+```
-- `for_agent(agent_id)`: 进入 Agent 上下文
-- `add_service(service_list)`: 注册指定服务
-- `list_services()`: 列出 Agent 可用服务
-- `list_tools()`: 列出 Agent 可用工具
-- `check_services()`: Agent 服务健康检查
-- `use_tool()`: 调用 Agent 可用工具
+Web 界面将在 http://localhost:5177 启动,自动连接到本地 API 服务(http://localhost:18200)。
-## 最佳实践
+#### 生产部署
-1. 合理使用 Store/Agent 模式
- - 全局工具使用 Store 模式
- - 特定场景使用 Agent 模式
+```bash
+cd vue
-2. 服务注册建议
- - Store 模式建议全量注册
- - Agent 模式按需注册
+# 构建生产版本
+npm run build
-3. 错误处理
- - 注册前检查服务可用性
- - 调用时做好异常处理
+# 预览生产构建
+npm run preview
+```
-## 常见问题
+构建产物位于 `vue/dist` 目录,可部署到任何静态文件服务器(Nginx、Apache 等)。
-1. 服务注册失败
- - 检查服务配置是否正确
- - 确认服务是否可访问
+#### 在线体验
-2. 工具调用失败
- - 验证工具名称格式
- - 检查参数是否完整
+如果不想本地部署,可以直接访问在线演示:[https://web.mcpstore.wiki](https://web.mcpstore.wiki)
-## 贡献指南
-欢迎提交 Issue 和 Pull Request 来帮助改进 MCPStore。
+### docker部署
-## 近期计划更新 🚀
-### API 增强
-- [ ] 完善现有 API 的参数验证和错误处理
-- [ ] 添加更多实用的工具方法
-- [ ] 提供更灵活的配置选项
-- [ ] 支持异步批量操作
-### 服务注册增强
-- [ ] 增强 `add_service` 的容错能力
-- [ ] 支持多种服务注册模式(单个、批量、条件注册)
-- [ ] 添加服务注册状态监控
-- [ ] 支持服务热更新
-- [ ] 支持自定义重试策略
+## Star History
-### LangChain 集成
-- [ ] 提供与 LangChain 的无缝集成接口
-- [ ] 支持 LangChain Agent 工具链
-- [ ] 实现 LangChain 工具的自动转换
-- [ ] 提供标准的 LangChain 工具模板
+
-### 配置文件管理
-- [ ] 增强 JSON 配置文件的处理能力
-- [ ] 支持配置文件的导入导出
-- [ ] 添加配置文件的版本控制
-- [ ] 提供配置文件的验证工具
-- [ ] 支持配置文件的动态更新
-- [ ] 添加配置文件的备份和恢复功能
+[](https://star-history.com/#whillhill/mcpstore&Date)
-### 开发者工具
-- [ ] 提供更详细的调试信息
-- [ ] 添加性能分析工具
-- [ ] 提供服务测试工具集
-- [ ] 完善开发文档
+
-## 许可证
+---
-[License 类型]
+McpStore 仍在高频更新中,欢迎反馈与建议。
diff --git a/README_en.md b/README_en.md
new file mode 100644
index 00000000..0d2d68bf
--- /dev/null
+++ b/README_en.md
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+---
+
+   
+
+
+
+[English](README_en.md) | [简体中文](README_zh.md)
+
+
+[Live Demo](https://web.mcpstore.wiki) | [Documentation](https://doc.mcpstore.wiki/) | [Quick Start](###simple-example)
+
+
+
+### What is mcpstore?
+
+mcpstore is a ready-to-use MCP service orchestration layer for developers: manage services with a unified Store and adapt MCP for use with AI frameworks like `LangChain` and others.
+
+### Simple Example
+
+First, initialize a store
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store()
+```
+
+Now you have a `store`, and you can simply add or manage your services around this `store`. The `store` will maintain and manage these MCP services.
+
+#### Add Your First Service to the Store
+
+```python
+# Add below the code above
+store.for_store().add_service({"mcpServers": {"mcpstore_wiki": {"url": "https://www.mcpstore.wiki/mcp"}}})
+store.for_store().wait_service("mcpstore_wiki")
+```
+
+Easily add services using the add method. The add_service method supports multiple MCP service configuration formats, and mainstream MCP configuration formats can be passed directly. The wait method is optional and synchronously waits for the service to be ready.
+
+#### Adapt MCP to Objects Required by LangChain
+
+```python
+tools = store.for_store().for_langchain().list_tools()
+print("loaded langchain tools:", len(tools))
+```
+
+Simply chain methods to intuitively adapt MCP to a tools list directly usable by LangChain.
+
+##### Framework Adapters
+
+More frameworks will be supported gradually.
+
+| Supported Frameworks | Get Tools |
+| --- | --- |
+| LangChain | `tools = store.for_store().for_langchain().list_tools()` |
+| LangGraph | `tools = store.for_store().for_langgraph().list_tools()` |
+| AutoGen | `tools = store.for_store().for_autogen().list_tools()` |
+| CrewAI | `tools = store.for_store().for_crewai().list_tools()` |
+| LlamaIndex | `tools = store.for_store().for_llamaindex().list_tools()` |
+
+#### Now You Can Use LangChain Normally
+
+```python
+# Add the code above
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+llm = ChatOpenAI(
+ temperature=0,
+ model="deepseek-chat",
+ api_key="sk-*****",
+ base_url="https://api.deepseek.com"
+)
+agent = create_agent(model=llm, tools=tools, system_prompt="You are an assistant, include emoji in your responses")
+events = agent.invoke({"messages": [{"role": "user", "content": "How to add services in mcpstore?"}]})
+print(events)
+```
+
+### Quick Start
+
+```bash
+pip install mcpstore
+```
+
+#### Agent Grouping
+
+Use `for_agent(agent_id)` to group MCP services
+
+```python
+agent_id1 = "agent1"
+store.for_agent(agent_id1).add_service({"name": "mcpstore_wiki", "url": "https://www.mcpstore.wiki/mcp"})
+
+agent_id2 = "agent2"
+store.for_agent(agent_id2).add_service({"name": "playwright", "command": "npx", "args": ["@playwright/mcp"]})
+
+agent1_tools = store.for_agent(agent_id1).list_tools()
+agent2_tools = store.for_agent(agent_id2).list_tools()
+```
+
+`store.for_agent(agent_id)` shares most functional interfaces with `store.for_store()`. Essentially, it creates a logical subset within the global scope through a grouping mechanism.
+
+Effectively isolate services by assigning dedicated services to different Agents, avoiding overly long contexts.
+
+Works well with aggregated service `hub_service` (experimental) and quick generation of A2A Agent Cards (planned support).
+
+
+
+#### Common Operations
+
+| Action | Command Example |
+|-------------|----------------------------------------------------------------------------------------|
+| Find Service | `store.for_store().find_service("service_name")` |
+| Update Service | `store.for_store().update_service("service_name", new_config)` |
+| Patch Service | `store.for_store().patch_service("service_name", {"headers": {"X-API-Key": "..."}})` |
+| Delete Service | `store.for_store().delete_service("service_name")` |
+| Restart Service | `store.for_store().restart_service("service_name")` |
+| Disconnect Service | `store.for_store().disconnect_service("service_name")` |
+| Health Check | `store.for_store().check_services()` |
+| Show Config | `store.for_store().show_config()` |
+| Service Info | `store.for_store().get_service_info("service_name")` |
+| Wait for Ready | `store.for_store().wait_service("service_name", timeout=30)` |
+| Hub Services | `store.for_agent(agent_id).hub_services()` |
+| List Agents | `store.for_store().list_agents()` |
+| List Services | `store.for_store().list_services()` |
+| List Tools | `store.for_store().list_tools()` |
+| Find Tool | `store.for_store().find_tool("tool_name")` |
+| Execute Tool | `store.for_store().call_tool("tool_name", {"k": "v"})` |
+
+#### Cache/Redis Backend
+
+Supports using Redis as a shared cache backend for sharing service and tool metadata across processes/multiple instances. Install additional dependencies:
+
+```bash
+pip install mcpstore[redis]
+# Or install separately: pip install redis
+```
+
+Usage: Pass in the `external_db` parameter during store initialization:
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store(
+ external_db={
+ "cache": {
+ "type": "redis",
+ "url": "redis://localhost:6379/0",
+ "password": None,
+ "namespace": "demo_namespace"
+ }
+ }
+)
+```
+For more `setup_store` configurations, see the documentation.
+
+### API Mode
+
+#### Start API
+
+Quick start via SDK
+```python
+from mcpstore import MCPStore
+prod_store = MCPStore.setup_store()
+prod_store.start_api_server(host="0.0.0.0", port=18200)
+```
+
+Or quick start using CLI
+```bash
+mcpstore run api
+```
+
+
+Example page: [Live Demo](https://web.mcpstore.wiki)
+
+
+#### Common API Endpoints
+
+```bash
+# Service Management
+POST /for_store/add_service
+GET /for_store/list_services
+POST /for_store/delete_service
+
+# Tool Operations
+GET /for_store/list_tools
+POST /for_store/use_tool
+
+# Runtime Status
+GET /for_store/get_stats
+GET /for_store/health
+```
+For more, see API documentation: [Documentation](https://doc.mcpstore.wiki/)
+
+### Web Interface
+
+mcpstore provides a Vue.js-based visual management interface that allows you to conveniently manage MCP services, view tool lists, execute tool calls, and more through a browser.
+
+#### Start with Docker (Recommended)
+
+The easiest way is to use Docker Compose to start the complete service stack (including API backend and Web frontend):
+
+```bash
+# Start all services (API + Web + Docs + Wiki)
+cd docker
+./start-all.sh
+
+# Or start Web and API services separately
+cd docker/web && docker-compose up -d
+cd docker/api && docker-compose up -d
+```
+
+After startup, access:
+- **Web Interface**: http://localhost:5177
+- **API Service**: http://localhost:18200
+
+#### Local Development Mode
+
+If you need to run the Web interface in a local development environment:
+
+**1. Start API Backend**
+
+```bash
+# Method 1: Using CLI
+mcpstore run api
+
+# Method 2: Using Python
+python -c "from mcpstore import MCPStore; store = MCPStore.setup_store(); store.start_api_server(host='0.0.0.0', port=18200)"
+```
+
+**2. Start Web Frontend**
+
+```bash
+cd vue
+
+# Install dependencies (first time only)
+npm install
+
+# Start development server
+npm run dev
+
+# Or specify host mode
+npm run dev:local # Local access only
+npm run dev:domain # Allow LAN access
+```
+
+The Web interface will start at http://localhost:5177 and automatically connect to the local API service (http://localhost:18200).
+
+#### Production Deployment
+
+```bash
+cd vue
+
+# Build production version
+npm run build
+
+# Preview production build
+npm run preview
+```
+
+Build artifacts are located in the `vue/dist` directory and can be deployed to any static file server (Nginx, Apache, etc.).
+
+#### Live Demo
+
+If you don't want to deploy locally, you can directly access the online demo: [https://web.mcpstore.wiki](https://web.mcpstore.wiki)
+
+
+### Docker Deployment
+
+
+
+## Star History
+
+
+
+[](https://star-history.com/#whillhill/mcpstore&Date)
+
+
+
+---
+
+McpStore is still under active development. Feedback and suggestions are welcome.
diff --git a/README_zh.md b/README_zh.md
new file mode 100644
index 00000000..69a3ce63
--- /dev/null
+++ b/README_zh.md
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+---
+
+   
+
+
+
+[English](README_en.md) | [简体中文](README_zh.md)
+
+
+[在线体验](https://web.mcpstore.wiki) | [详细文档](https://doc.mcpstore.wiki/) | [快速使用](###简单示例)
+
+
+
+### mcpstore 是什么?
+
+mcpstore 是面向开发者的开箱即用的 MCP 服务编排层:用一个 Store 统一管理服务,并将 MCP 适配给 AI 框架`LangChain等`使用。
+
+### 简单示例
+
+首先只需要需要初始化一个store
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store()
+```
+
+现在就有了一个 `store`,后续只需要围绕这个`store`去添加或者操作你的服务,`store` 会维护和管理这些 MCP 服务。
+
+#### 给store添加第一个服务
+
+```python
+#在上面的代码下面加入
+store.for_store().add_service({"mcpServers": {"mcpstore_wiki": {"url": "https://www.mcpstore.wiki/mcp"}}})
+store.for_store().wait_service("mcpstore_wiki")
+```
+
+通过add方法便捷添加服务,add_service方法支持多种mcp服务配置格式,主流的mcp配置格式都可以直接传入。wait方法可选,是否同步等待服务就绪。
+
+#### 将mcp适配转为langchain需要的对象
+
+```python
+tools = store.for_store().for_langchain().list_tools()
+print("loaded langchain tools:", len(tools))
+```
+
+简单链上即可直观的将mcp适配为langchain直接使用的tools列表
+
+##### 框架适配
+
+会逐渐支持更多的框架
+
+| 已支持框架 | 获取工具 |
+| --- | --- |
+| LangChain | `tools = store.for_store().for_langchain().list_tools()` |
+| LangGraph | `tools = store.for_store().for_langgraph().list_tools()` |
+| AutoGen | `tools = store.for_store().for_autogen().list_tools()` |
+| CrewAI | `tools = store.for_store().for_crewai().list_tools()` |
+| LlamaIndex | `tools = store.for_store().for_llamaindex().list_tools()` |
+
+#### 现在就可以正常的使用langchain了
+
+```python
+#添加上面的代码
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+llm = ChatOpenAI(
+ temperature=0,
+ model="deepseek-chat",
+ api_key="sk-*****",
+ base_url="https://api.deepseek.com"
+)
+agent = create_agent(model=llm, tools=tools, system_prompt="你是一个助手,回答的时候带上表情")
+events = agent.invoke({"messages": [{"role": "user", "content": "mcpstore怎么添加服务?"}]})
+print(events)
+```
+
+### 快速开始
+
+```bash
+pip install mcpstore
+```
+
+#### Agent 分组
+
+使用 `for_agent(agent_id)` 实现对mcp服务进行分组
+
+```python
+agent_id1 = "agent1"
+store.for_agent(agent_id1).add_service({"name": "mcpstore_wiki", "url": "https://www.mcpstore.wiki/mcp"})
+
+agent_id2 = "agent2"
+store.for_agent(agent_id2).add_service({"name": "playwright", "command": "npx", "args": ["@playwright/mcp"]})
+
+agent1_tools = store.for_agent(agent_id1).list_tools()
+agent2_tools = store.for_agent(agent_id2).list_tools()
+```
+
+`store.for_agent(agent_id)` 与 `store.for_store()` 共享大部分函数接口,本质上是通过分组机制在全局范围内创建了一个逻辑子集。
+
+通过为不同 Agent 分配专属服务实现服务的有效隔离,避免上下文过长。
+
+与聚合服务`hub_service`(实验性)和快速生成 A2A Agent Card (计划支持)配合较好。
+
+
+
+#### 常用操作
+
+| 动作 | 命令示例 |
+|-------------|----------------------------------------------------------------------------------------|
+| 定位服务 | `store.for_store().find_service("service_name")` |
+| 更新服务 | `store.for_store().update_service("service_name", new_config)` |
+| 增量更新 | `store.for_store().patch_service("service_name", {"headers": {"X-API-Key": "..."}})` |
+| 删除服务 | `store.for_store().delete_service("service_name")` |
+| 重启服务 | `store.for_store().restart_service("service_name")` |
+| 断开服务 | `store.for_store().disconnect_service("service_name")` |
+| 健康检查 | `store.for_store().check_services()` |
+| 查看配置 | `store.for_store().show_config()` |
+| 服务详情 | `store.for_store().get_service_info("service_name")` |
+| 等待就绪 | `store.for_store().wait_service("service_name", timeout=30)` |
+| 聚合服务 | `store.for_agent(agent_id).hub_services()` |
+| 列出Agent | `store.for_store().list_agents()` |
+| 列出服务 | `store.for_store().list_services()` |
+| 列出工具 | `store.for_store().list_tools()` |
+| 定位工具 | `store.for_store().find_tool("tool_name")` |
+| 执行工具 | `store.for_store().call_tool("tool_name", {"k": "v"})` |
+
+#### 缓存/Redis 后端
+
+支持使用 Redis 作为共享缓存后端,用于跨进程/多实例共享服务与工具元数据。安装额外依赖:
+
+```bash
+pip install mcpstore[redis]
+#或直接 单独 pip install redis
+```
+
+使用方式:在store初始化的时候通过 `external_db` 参数传入:
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store(
+ external_db={
+ "cache": {
+ "type": "redis",
+ "url": "redis://localhost:6379/0",
+ "password": None,
+ "namespace": "demo_namespace"
+ }
+ }
+)
+```
+更多的`setup_store`配置见文档
+
+### API 模式
+
+#### 启动api
+
+通过SDK快速启动
+```python
+from mcpstore import MCPStore
+prod_store = MCPStore.setup_store()
+prod_store.start_api_server(host="0.0.0.0", port=18200)
+```
+
+或者使用CLI快速启动
+```bash
+mcpstore run api
+```
+
+
+示例页面:[在线体验](https://web.mcpstore.wiki)
+
+
+#### 常用接口
+
+```bash
+# 服务管理
+POST /for_store/add_service
+GET /for_store/list_services
+POST /for_store/delete_service
+
+# 工具操作
+GET /for_store/list_tools
+POST /for_store/use_tool
+
+# 运行状态
+GET /for_store/get_stats
+GET /for_store/health
+```
+更多见接口文档: [详细文档](https://doc.mcpstore.wiki/)
+
+### Web 界面
+
+mcpstore 提供了基于 Vue.js 的可视化管理界面,可以通过浏览器方便地管理 MCP 服务、查看工具列表、执行工具调用等操作。
+
+#### 使用 Docker 启动(推荐)
+
+最简单的方式是使用 Docker Compose 启动完整的服务栈(包括 API 后端和 Web 前端):
+
+```bash
+# 启动所有服务(API + Web + 文档 + Wiki)
+cd docker
+./start-all.sh
+
+# 或单独启动 Web 和 API 服务
+cd docker/web && docker-compose up -d
+cd docker/api && docker-compose up -d
+```
+
+启动后访问:
+- **Web 界面**: http://localhost:5177
+- **API 服务**: http://localhost:18200
+
+#### 本地开发模式
+
+如果需要在本地开发环境运行 Web 界面:
+
+**1. 启动 API 后端**
+
+```bash
+# 方式一:使用 CLI
+mcpstore run api
+
+# 方式二:使用 Python
+python -c "from mcpstore import MCPStore; store = MCPStore.setup_store(); store.start_api_server(host='0.0.0.0', port=18200)"
+```
+
+**2. 启动 Web 前端**
+
+```bash
+cd vue
+
+# 安装依赖(首次运行)
+npm install
+
+# 启动开发服务器
+npm run dev
+
+# 或指定主机模式
+npm run dev:local # 仅本机访问
+npm run dev:domain # 允许局域网访问
+```
+
+Web 界面将在 http://localhost:5177 启动,自动连接到本地 API 服务(http://localhost:18200)。
+
+#### 生产部署
+
+```bash
+cd vue
+
+# 构建生产版本
+npm run build
+
+# 预览生产构建
+npm run preview
+```
+
+构建产物位于 `vue/dist` 目录,可部署到任何静态文件服务器(Nginx、Apache 等)。
+
+#### 在线体验
+
+如果不想本地部署,可以直接访问在线演示:[https://web.mcpstore.wiki](https://web.mcpstore.wiki)
+
+
+### docker部署
+
+
+
+## Star History
+
+
+
+[](https://star-history.com/#whillhill/mcpstore&Date)
+
+
+
+---
+
+McpStore 仍在高频更新中,欢迎反馈与建议。
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 00000000..5a3b4f11
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 00000000..f49bb394
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,122 @@
+# MCPStore Docker Service Deployment
+
+## 🏗️ Service Architecture
+
+MCPStore now adopts a microservices architecture, containing 4 independent services:
+
+- **doc**: MkDocs Documentation Service (Port: 8000)
+- **web**: Vue.js Frontend Service (Port: 5177)
+- **api**: MCPStore API Backend Service (Port: 18200)
+- **wiki**: MCP Wiki Service (Port: 21923)
+
+## 🚀 Quick Start
+
+### Start Individual Services
+
+Each service has its own Docker Compose configuration:
+
+```bash
+# Start documentation service
+cd docker/doc && docker-compose up -d
+
+# Start frontend service
+cd docker/web && docker-compose up -d
+
+# Start API service
+cd docker/api && docker-compose up -d
+
+# Start Wiki service
+cd docker/wiki && docker-compose up -d
+```
+
+### Batch Startup Scripts
+
+```bash
+# Start all services
+./start-all.sh
+
+# Stop all services
+./stop-all.sh
+
+# Check service status
+./status.sh
+```
+
+## 📁 Directory Structure
+
+```
+docker/
+├── doc/ # Documentation service
+│ ├── Dockerfile
+│ ├── docker-compose.yml
+│ └── README.md
+├── web/ # Frontend service
+│ ├── Dockerfile
+│ ├── docker-compose.yml
+│ └── README.md
+├── api/ # API service
+│ ├── Dockerfile
+│ ├── docker-compose.yml
+│ ├── start_api.py
+│ └── README.md
+├── wiki/ # Wiki service
+│ ├── Dockerfile
+│ ├── docker-compose.yml
+│ ├── requirements.txt
+│ └── README.md
+├── start-all.sh # Start all services
+├── stop-all.sh # Stop all services
+├── status.sh # Check status
+└── README.md # This file
+```
+
+## 🌐 Service Access
+
+After successful startup, you can access each service at the following addresses:
+
+- **Documentation**: http://localhost:8000
+- **Frontend**: http://localhost:5177
+- **API**: http://localhost:18200
+- **Wiki**: http://localhost:21923/mcp
+
+## 🔧 Development Mode
+
+For development, you can mount source code to enable hot reload:
+
+```bash
+# API service development mode
+cd docker/api
+docker-compose -f docker-compose.dev.yml up -d
+
+# Frontend service development mode
+cd docker/web
+docker-compose -f docker-compose.dev.yml up -d
+```
+
+## 📊 Monitoring
+
+Each service includes health checks and log output:
+
+```bash
+# View service logs
+docker-compose logs -f
+
+# Check health status
+docker-compose ps
+```
+
+## 🔒 Production Deployment
+
+Production environment recommended configurations:
+
+1. **Environment Variables**: Use `.env` files to manage sensitive configurations
+2. **Network Isolation**: Configure firewall rules
+3. **Log Collection**: Centralized log collection and management
+4. **Monitoring Alerts**: Configure Prometheus + Grafana
+
+## ⚠️ Important Notes
+
+1. Ensure ports 8000, 5177, 18200, 21923 are not occupied
+2. First-time build may require significant time to download dependencies
+3. Recommend using Docker Compose v2.0+
+4. Set appropriate resource limits for production environment
\ No newline at end of file
diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile
new file mode 100644
index 00000000..56fed1ba
--- /dev/null
+++ b/docker/api/Dockerfile
@@ -0,0 +1,46 @@
+# MCPStore API Service
+FROM python:3.11-slim
+
+# Set environment variables
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Set working directory
+WORKDIR /app
+
+# Copy project files
+COPY src ./src
+COPY pyproject.toml .
+COPY README.md .
+
+# Copy startup script
+COPY docker/api/start_api.py .
+
+# Install project dependencies using uv
+RUN uv pip install --system -e .
+
+# Create data directories
+RUN mkdir -p /app/data /app/config /app/logs
+
+# Set script permissions
+RUN chmod +x start_api.py
+
+# Expose port
+EXPOSE 18200
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
+ CMD curl -f http://localhost:18200/health || exit 1
+
+# Start command
+CMD ["python", "start_api.py"]
diff --git a/docker/api/docker-compose.yml b/docker/api/docker-compose.yml
new file mode 100644
index 00000000..aa62d2dc
--- /dev/null
+++ b/docker/api/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+ api:
+ build:
+ context: ../..
+ dockerfile: docker/api/Dockerfile
+ container_name: mcpstore-api
+ restart: unless-stopped
+ ports:
+ - "18200:18200"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - PYTHONDONTWRITEBYTECODE=1
+ - DEBUG=false
+ - LOG_LEVEL=info
+ - MCPSTORE_CONFIG_DIR=/app/config
+ - MCPSTORE_DATA_DIR=/app/data
+ volumes:
+ - api-data:/app/data
+ - api-config:/app/config
+ - api-logs:/app/logs
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:18200/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - mcpstore-network
+
+volumes:
+ api-data:
+ driver: local
+ api-config:
+ driver: local
+ api-logs:
+ driver: local
+
+networks:
+ mcpstore-network:
+ driver: bridge
\ No newline at end of file
diff --git a/docker/api/start_api.py b/docker/api/start_api.py
new file mode 100644
index 00000000..4ef99c60
--- /dev/null
+++ b/docker/api/start_api.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""
+MCPStore API Service Startup Script
+Based on docker/exp_run_service.py
+"""
+
+import sys
+import os
+
+# Add source path to Python path
+sys.path.insert(0, '/app/src')
+
+from mcpstore import MCPStore
+
+
+def main():
+ """Start MCPStore API server"""
+ print("=" * 60)
+ print("MCPStore API Server Starting")
+ print("=" * 60)
+ print(f"[i] Host: 0.0.0.0")
+ print(f"[i] Port: 18200")
+ print(f"[i] Debug: {os.getenv('DEBUG', 'False').lower() == 'true'}")
+ print(f"[i] Log Level: {os.getenv('LOG_LEVEL', 'info')}")
+ print("-" * 60)
+
+ # Initialize production store
+ print("[i] Initializing MCPStore...")
+
+ # Configure using environment variables
+ debug = os.getenv('DEBUG', 'False').lower() == 'true'
+ log_level = os.getenv('LOG_LEVEL', 'info')
+
+ try:
+ store = MCPStore.setup_store(debug=debug)
+ print("[✓] MCPStore initialized successfully")
+
+ # Start API server
+ print("[i] Starting API server...")
+
+ store.start_api_server(
+ host='0.0.0.0',
+ port=18200,
+ log_level=log_level,
+ reload=False, # Do not enable hot reload in production
+ auto_open_browser=False,
+ show_startup_info=True
+ )
+
+ except Exception as e:
+ print(f"[!] Startup failed: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/docker/doc/Dockerfile b/docker/doc/Dockerfile
new file mode 100644
index 00000000..60706a28
--- /dev/null
+++ b/docker/doc/Dockerfile
@@ -0,0 +1,42 @@
+# MkDocs Multi-stage Build
+FROM python:3.11-slim
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Set working directory
+WORKDIR /app
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Copy requirements file
+COPY docs/requirements.txt .
+
+# Install dependencies using uv
+RUN uv pip install --system -r requirements.txt
+
+# Copy documentation files
+COPY docs/mkdocs.yml ./mkdocs.yml
+COPY docs/docs ./docs
+COPY docs/assets ./assets
+
+# Create logs directory
+RUN mkdir -p /app/logs
+
+# Set environment variables
+ENV PYTHONUNBUFFERED=1
+ENV MKDOCS_CONFIG=/app/mkdocs.yml
+ENV LOG_LEVEL=INFO
+
+# Expose port
+EXPOSE 8000
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8000/ || exit 1
+
+# Start command
+CMD ["mkdocs", "serve", "--host", "0.0.0.0", "--port", "8000", "--config-file", "/app/mkdocs.yml"]
diff --git a/docker/doc/docker-compose.yml b/docker/doc/docker-compose.yml
new file mode 100644
index 00000000..c6edd5a4
--- /dev/null
+++ b/docker/doc/docker-compose.yml
@@ -0,0 +1,34 @@
+services:
+ doc:
+ build:
+ context: ../..
+ dockerfile: docker/doc/Dockerfile
+ container_name: mcpstore-doc
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - MKDOCS_CONFIG=/app/mkdocs.yml
+ - LOG_LEVEL=INFO
+ volumes:
+ - ../docs:/app/docs:ro
+ - ../docs/mkdocs.yml:/app/mkdocs.yml:ro
+ - ../docs/assets:/app/assets:ro
+ - doc-logs:/app/logs
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - mcpstore-network
+
+volumes:
+ doc-logs:
+ driver: local
+
+networks:
+ mcpstore-network:
+ driver: bridge
\ No newline at end of file
diff --git a/docker/start-all.sh b/docker/start-all.sh
new file mode 100644
index 00000000..53ea7b69
--- /dev/null
+++ b/docker/start-all.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+
+# MCPStore All Services Startup Script
+
+echo "🚀 Starting MCPStore All Services..."
+echo "================================"
+
+# Define service list
+services=("doc" "web" "api" "wiki")
+
+# Define port mapping
+declare -A ports=(
+ ["doc"]="8000"
+ ["web"]="5177"
+ ["api"]="18200"
+ ["wiki"]="21923"
+)
+
+# Color definitions
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Check if port is occupied
+check_port() {
+ local port=$1
+ if lsof -i :$port >/dev/null 2>&1; then
+ echo -e "${RED}Warning: Port $port is already in use${NC}"
+ return 1
+ fi
+ return 0
+}
+
+# Start single service
+start_service() {
+ local service=$1
+ local port=${ports[$service]}
+
+ echo -e "${YELLOW}Starting $service service (port: $port)...${NC}"
+
+ if check_port $port; then
+ cd "$(dirname "$0")/$service"
+ if docker-compose up -d --build; then
+ echo -e "${GREEN}✓ $service service started successfully${NC}"
+ cd - > /dev/null
+ return 0
+ else
+ echo -e "${RED}✗ $service service startup failed${NC}"
+ cd - > /dev/null
+ return 1
+ fi
+ else
+ echo -e "${RED}Skipping $service service (port occupied)${NC}"
+ return 1
+ fi
+}
+
+# Check Docker and Docker Compose
+if ! command -v docker &> /dev/null; then
+ echo -e "${RED}Error: Docker is not installed${NC}"
+ exit 1
+fi
+
+if ! command -v docker-compose &> /dev/null; then
+ echo -e "${RED}Error: Docker Compose is not installed${NC}"
+ exit 1
+fi
+
+# Start all services
+failed_services=()
+success_count=0
+
+for service in "${services[@]}"; do
+ if start_service "$service"; then
+ ((success_count++))
+ else
+ failed_services+=("$service")
+ fi
+ echo ""
+done
+
+# Output startup results
+echo "================================"
+echo -e "${GREEN}Successfully started $success_count/${#services[@]} services${NC}"
+
+if [ ${#failed_services[@]} -gt 0 ]; then
+ echo -e "${RED}Failed services: ${failed_services[*]}${NC}"
+fi
+
+echo ""
+echo "🌐 Service Access URLs:"
+for service in "${services[@]}"; do
+ local port=${ports[$service]}
+ echo " - $service: http://localhost:$port"
+done
+
+echo ""
+echo "📊 Check service status: ./status.sh"
+echo "🛑 Stop all services: ./stop-all.sh"
\ No newline at end of file
diff --git a/docker/status.sh b/docker/status.sh
new file mode 100644
index 00000000..939e9a0f
--- /dev/null
+++ b/docker/status.sh
@@ -0,0 +1,130 @@
+#!/bin/bash
+
+# MCPStore Service Status Check Script
+
+echo "📊 MCPStore Service Status"
+echo "================================"
+
+# Define service list
+services=("doc" "web" "api" "wiki")
+
+# Define port mapping
+declare -A ports=(
+ ["doc"]="8000"
+ ["web"]="5177"
+ ["api"]="18200"
+ ["wiki"]="21923"
+)
+
+# Color definitions
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Check if port is open
+check_port() {
+ local port=$1
+ if lsof -i :$port >/dev/null 2>&1; then
+ return 0
+ fi
+ return 1
+}
+
+# Check Docker Container Status
+check_container() {
+ local service="mcpstore-$service"
+ if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "$service"; then
+ return 0
+ fi
+ return 1
+}
+
+# Check HTTP response
+check_http() {
+ local url=$1
+ if curl -s -o /dev/null -w "%{http_code}" "$url" | grep -q "200\|404"; then
+ return 0
+ fi
+ return 1
+}
+
+# Output service status
+print_service_status() {
+ local service=$1
+ local port=${ports[$service]}
+ local container_name="mcpstore-$service"
+ local url="http://localhost:$port"
+
+ printf "%-10s (Port %-5s): " "$service" "$port"
+
+ # Check port
+ if check_port $port; then
+ echo -ne "${GREEN}✓ Port Open${NC}"
+ else
+ echo -ne "${RED}✗ Port Closed${NC}"
+ return
+ fi
+
+ # Check container
+ if docker ps --format "table {{.Names}}" | grep -q "$container_name"; then
+ echo -ne " | ${GREEN}✓ Container Running${NC}"
+ else
+ echo -ne " | ${RED}✗ Container Stopped${NC}"
+ fi
+
+ # Check HTTP response
+ case $service in
+ "api")
+ if curl -s -o /dev/null -w "%{http_code}" "$url/health" | grep -q "200"; then
+ echo -ne " | ${GREEN}✓ API Health${NC}"
+ else
+ echo -ne " | ${YELLOW}⚠ API Error${NC}"
+ fi
+ ;;
+ "wiki")
+ if curl -s -o /dev/null -w "%{http_code}" "$url/mcp" | grep -q "200\|404"; then
+ echo -ne " | ${GREEN}✓ Wiki Health${NC}"
+ else
+ echo -ne " | ${YELLOW}⚠ Wiki Error${NC}"
+ fi
+ ;;
+ *)
+ if check_http "$url"; then
+ echo -ne " | ${GREEN}✓ HTTP Response${NC}"
+ else
+ echo -ne " | ${YELLOW}⚠ HTTP Error${NC}"
+ fi
+ ;;
+ esac
+
+ echo ""
+}
+
+# Display container details
+echo -e "${BLUE}Docker Container Status:${NC}"
+docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep mcpstore || echo " No running MCPStore containers"
+
+echo ""
+echo -e "${BLUE}Service Health Status:${NC}"
+for service in "${services[@]}"; do
+ print_service_status "$service"
+done
+
+echo ""
+echo -e "${BLUE}Service Access URLs:${NC}"
+for service in "${services[@]}"; do
+ local port=${ports[$service]}
+ echo " - $service: http://localhost:$port"
+done
+
+echo ""
+echo -e "${BLUE}Log View Commands:${NC}"
+for service in "${services[@]}"; do
+ echo " - $service: cd docker/$service && docker-compose logs -f"
+done
+
+echo ""
+echo "🔄 Restart Services: ./start-all.sh"
+echo "🛑 Stop All Services: ./stop-all.sh"
\ No newline at end of file
diff --git a/docker/stop-all.sh b/docker/stop-all.sh
new file mode 100644
index 00000000..1f87f8c0
--- /dev/null
+++ b/docker/stop-all.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+
+# MCPStore All Services Stop Script
+
+echo "🛑 Stopping MCPStore All Services..."
+echo "================================"
+
+# Define service list
+services=("doc" "web" "api" "wiki")
+
+# Color definitions
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Stop single service
+stop_service() {
+ local service=$1
+ echo -e "${YELLOW}Stopping $service service...${NC}"
+
+ cd "$(dirname "$0")/$service"
+ if docker-compose down; then
+ echo -e "${GREEN}✓ $service service stopped${NC}"
+ cd - > /dev/null
+ return 0
+ else
+ echo -e "${RED}✗ $service service stop failed${NC}"
+ cd - > /dev/null
+ return 1
+ fi
+}
+
+# Stop all services
+failed_services=()
+success_count=0
+
+for service in "${services[@]}"; do
+ if stop_service "$service"; then
+ ((success_count++))
+ else
+ failed_services+=("$service")
+ fi
+ echo ""
+done
+
+# Clean up unused containers and networks (optional)
+read -p "Clean up unused Docker resources? (y/N): " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo -e "${YELLOW}Cleaning up unused Docker resources...${NC}"
+ docker system prune -f
+fi
+
+# Output stop results
+echo "================================"
+echo -e "${GREEN}Successfully stopped $success_count/${#services[@]} services${NC}"
+
+if [ ${#failed_services[@]} -gt 0 ]; then
+ echo -e "${RED}Failed services: ${failed_services[*]}${NC}"
+fi
+
+echo ""
+echo "🔄 Restart all services: ./start-all.sh"
+echo "📊 Check service status: ./status.sh"
\ No newline at end of file
diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile
new file mode 100644
index 00000000..176cfeb2
--- /dev/null
+++ b/docker/web/Dockerfile
@@ -0,0 +1,69 @@
+# Vue.js Multi-stage Build
+FROM node:18-alpine AS builder
+
+# Set working directory
+WORKDIR /build
+
+# Install pnpm (recommended) or use npm
+RUN npm install -g pnpm
+
+# Copy package.json and lock files
+COPY vue/package.json vue/pnpm-lock.yaml ./
+
+# Install dependencies
+RUN pnpm install --frozen-lockfile
+
+# Copy source code
+COPY vue/ .
+
+# Build application
+RUN pnpm build
+
+# Production stage - Serve static files with nginx
+FROM nginx:alpine
+
+# Install curl for health checks
+RUN apk add --no-cache curl
+
+# Copy build artifacts
+COPY --from=builder /build/dist /usr/share/nginx/html
+
+# Copy nginx configuration
+RUN echo 'server { \
+ listen 5177; \
+ server_name _; \
+ root /usr/share/nginx/html; \
+ index index.html; \
+ \
+ location / { \
+ try_files $$uri $$uri/ /index.html; \
+ } \
+ \
+ # Support reverse proxy for API requests to local API service \
+ location /api/ { \
+ proxy_pass http://host.docker.internal:18200/; \
+ proxy_set_header Host $$host; \
+ proxy_set_header X-Real-IP $$remote_addr; \
+ proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for; \
+ proxy_set_header X-Forwarded-Proto $$scheme; \
+ proxy_http_version 1.1; \
+ proxy_set_header Upgrade $$http_upgrade; \
+ proxy_set_header Connection "upgrade"; \
+ } \
+ \
+ # Static resource caching \
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$$ { \
+ expires 1y; \
+ add_header Cache-Control "public, immutable"; \
+ } \
+}' > /etc/nginx/conf.d/default.conf
+
+# Expose port
+EXPOSE 5177
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:5177/ || exit 1
+
+# Start nginx
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/docker/web/docker-compose.yml b/docker/web/docker-compose.yml
new file mode 100644
index 00000000..cfe3fd0d
--- /dev/null
+++ b/docker/web/docker-compose.yml
@@ -0,0 +1,37 @@
+services:
+ web:
+ build:
+ context: ../..
+ dockerfile: docker/web/Dockerfile
+ container_name: mcpstore-web
+ restart: unless-stopped
+ ports:
+ - "5177:5177"
+ environment:
+ - NODE_ENV=production
+ - VITE_API_BASE=http://localhost:18200
+ - VITE_APP_TITLE=MCPStore
+ volumes:
+ - web-logs:/var/log/nginx
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:5177/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - mcpstore-network
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ # 开发模式可以选择性地挂载源码进行热重载
+ # volumes:
+ # - ../vue/src:/usr/share/nginx/html/src:ro
+ # - ../vue/public:/usr/share/nginx/html/public:ro
+
+volumes:
+ web-logs:
+ driver: local
+
+networks:
+ mcpstore-network:
+ driver: bridge
diff --git a/docker/wiki/Dockerfile b/docker/wiki/Dockerfile
new file mode 100644
index 00000000..d3e17e22
--- /dev/null
+++ b/docker/wiki/Dockerfile
@@ -0,0 +1,44 @@
+# MCP Wiki Service
+FROM python:3.11-slim
+
+# Set environment variables
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ LOG_LEVEL=INFO
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Set working directory
+WORKDIR /app
+
+# Copy requirements file
+COPY docker/wiki/requirements.txt .
+
+# Install dependencies using uv
+RUN uv pip install --system -r requirements.txt
+
+# Copy wiki service file
+COPY wiki/mcp_service_wiki.py .
+
+# Create logs directory
+RUN mkdir -p /app/logs
+
+# Set script permissions
+RUN chmod +x mcp_service_wiki.py
+
+# Expose port
+EXPOSE 21923
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
+ CMD curl -f http://localhost:21923/mcp || exit 1
+
+# Start command
+CMD ["python", "mcp_service_wiki.py"]
diff --git a/docker/wiki/docker-compose.yml b/docker/wiki/docker-compose.yml
new file mode 100644
index 00000000..1c7b32c1
--- /dev/null
+++ b/docker/wiki/docker-compose.yml
@@ -0,0 +1,35 @@
+services:
+ wiki:
+ build:
+ context: ../..
+ dockerfile: docker/wiki/Dockerfile
+ container_name: mcpstore-wiki
+ restart: unless-stopped
+ ports:
+ - "21923:21923"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - PYTHONDONTWRITEBYTECODE=1
+ - LOG_LEVEL=INFO
+ - LOG_DIR=/app/logs
+ volumes:
+ - wiki-logs:/app/logs
+ - wiki-data:/app/data
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:21923/mcp"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - mcpstore-network
+
+volumes:
+ wiki-logs:
+ driver: local
+ wiki-data:
+ driver: local
+
+networks:
+ mcpstore-network:
+ driver: bridge
\ No newline at end of file
diff --git a/docker/wiki/requirements.txt b/docker/wiki/requirements.txt
new file mode 100644
index 00000000..3bd54017
--- /dev/null
+++ b/docker/wiki/requirements.txt
@@ -0,0 +1,5 @@
+# MCP Wiki 服务依赖
+fastmcp>=2.7.1
+pydantic>=2.11.5
+uvicorn>=0.30.0
+python-multipart>=0.0.6
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..9d21fe5c
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,63 @@
+### 文档站点(MkDocs)使用说明
+
+本项目使用 MkDocs 构建文档站点,主题为 Material。以下为最小可用步骤与常用命令。
+
+#### 1. 环境与依赖安装
+- Python 3.8+
+- 安装依赖(推荐)
+```bash
+pip install -r docs/requirements.txt
+```
+- 可选插件(按需)
+```bash
+pip install markdown-checklist
+pip install mkdocs-git-revision-date-localized-plugin
+```
+(`requirements.txt` 已包含:mkdocs、mkdocs-material、mkdocs-minify-plugin、pymdown-extensions)
+
+#### 2. 主题
+- 使用主题:Material(包名:mkdocs-material)
+- 在 mkdocs.yml 中声明(示例):
+```yaml
+theme:
+ name: material
+```
+
+#### 3. 启动(本地预览)
+在包含 mkdocs.yml 的目录执行:
+```bash
+mkdocs serve -a 127.0.0.1:8000
+```
+- 启动命令参数说明:
+ - `-a, --dev-addr `:绑定地址与端口(缺省为 127.0.0.1:8000)
+ - `-f, --config-file `:指定配置文件(默认 `mkdocs.yml`)
+
+访问:http://127.0.0.1:8000/
+
+#### 4. 构建(生成静态站点)
+```bash
+mkdocs build -d site
+```
+- 常用参数:
+ - `-d, --site-dir `:输出目录(默认 `site/`)
+ - `-f, --config-file `:指定配置文件
+
+#### 5. 从零新建站点(可选)
+若你需要在空目录初始化:
+```bash
+mkdocs new mysite
+cd mysite
+mkdocs serve
+```
+然后根据需要安装插件:
+```bash
+pip install markdown-checklist mkdocs-minify-plugin mkdocs-git-revision-date-localized-plugin
+```
+
+#### 6. 目录与文件
+- `docs/`:放置 Markdown 文档
+- `docs/requirements.txt`:MkDocs 及主题/插件依赖清单
+- `mkdocs.yml`:站点配置(主题、导航、插件等)
+
+> 提示:若尚未创建 `mkdocs.yml`,请在项目根目录新增并配置主题、导航与插件。
+
diff --git a/docs/docs/api/reference.md b/docs/docs/api/reference.md
new file mode 100644
index 00000000..08fa63d2
--- /dev/null
+++ b/docs/docs/api/reference.md
@@ -0,0 +1,621 @@
+# API 参考文档
+
+## 📋 概述
+
+本文档提供了 MCPStore 的完整 API 参考,包括所有类、方法、参数和返回值的详细说明。
+
+## 🏗️ 核心类
+
+### MCPStore
+
+MCPStore 的主要类,提供所有核心功能。
+
+```python
+class MCPStore:
+ """MCPStore 主类"""
+
+ def __init__(self, config: Optional[Dict] = None, config_file: Optional[str] = None):
+ """
+ 初始化 MCPStore
+
+ Args:
+ config: 配置字典
+ config_file: 配置文件路径
+ """
+```
+
+#### 服务管理方法
+
+##### add_service()
+
+```python
+def add_service(self, config: Union[Dict, str, Path]) -> bool:
+ """
+ 添加 MCP 服务
+
+ Args:
+ config: 服务配置,支持以下格式:
+ - 字典格式:{"mcpServers": {"service_name": {...}}}
+ - JSON 文件路径
+ - 配置字典
+
+ Returns:
+ bool: 添加是否成功
+
+ Raises:
+ ConfigurationError: 配置格式错误
+ ServiceRegistrationError: 服务注册失败
+
+ Example:
+ >>> store.add_service({
+ ... "mcpServers": {
+ ... "filesystem": {
+ ... "command": "npx",
+ ... "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ ... }
+ ... }
+ ... })
+ True
+ """
+```
+
+##### list_services()
+
+```python
+def list_services(self) -> List[Dict[str, Any]]:
+ """
+ 列出所有已注册的服务
+
+ Returns:
+ List[Dict]: 服务列表,每个服务包含以下字段:
+ - name (str): 服务名称
+ - status (str): 服务状态
+ - command (str): 启动命令
+ - args (List[str]): 命令参数
+ - pid (Optional[int]): 进程ID
+ - uptime (float): 运行时间(秒)
+
+ Example:
+ >>> services = store.list_services()
+ >>> print(services[0]['name'])
+ 'filesystem'
+ """
+```
+
+##### start_service()
+
+```python
+def start_service(self, service_name: str, timeout: Optional[float] = 30.0) -> bool:
+ """
+ 启动指定服务
+
+ Args:
+ service_name: 服务名称
+ timeout: 启动超时时间(秒)
+
+ Returns:
+ bool: 启动是否成功
+
+ Raises:
+ ServiceNotFoundError: 服务不存在
+ ServiceStartError: 服务启动失败
+ ServiceTimeoutError: 启动超时
+
+ Example:
+ >>> store.start_service("filesystem")
+ True
+ """
+```
+
+##### stop_service()
+
+```python
+def stop_service(self, service_name: str, timeout: Optional[float] = 30.0, force: bool = False) -> bool:
+ """
+ 停止指定服务
+
+ Args:
+ service_name: 服务名称
+ timeout: 停止超时时间(秒)
+ force: 是否强制停止
+
+ Returns:
+ bool: 停止是否成功
+
+ Raises:
+ ServiceNotFoundError: 服务不存在
+ ServiceStopError: 服务停止失败
+
+ Example:
+ >>> store.stop_service("filesystem")
+ True
+ """
+```
+
+##### restart_service()
+
+```python
+def restart_service(self, service_name: str, timeout: Optional[float] = 60.0) -> bool:
+ """
+ 重启指定服务
+
+ Args:
+ service_name: 服务名称
+ timeout: 重启超时时间(秒)
+
+ Returns:
+ bool: 重启是否成功
+
+ Raises:
+ ServiceNotFoundError: 服务不存在
+ ServiceRestartError: 服务重启失败
+
+ Example:
+ >>> store.restart_service("filesystem")
+ True
+ """
+```
+
+##### get_service_status()
+
+```python
+def get_service_status(self, service_name: str) -> str:
+ """
+ 获取服务状态
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ str: 服务状态,可能的值:
+ - "not_started": 未启动
+ - "starting": 启动中
+ - "running": 运行中
+ - "stopping": 停止中
+ - "stopped": 已停止
+ - "error": 错误状态
+
+ Raises:
+ ServiceNotFoundError: 服务不存在
+
+ Example:
+ >>> status = store.get_service_status("filesystem")
+ >>> print(status)
+ 'running'
+ """
+```
+
+##### get_service_info()
+
+```python
+def get_service_info(self, service_name: str) -> Dict[str, Any]:
+ """
+ 获取服务详细信息
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ Dict: 服务信息,包含以下字段:
+ - name (str): 服务名称
+ - status (str): 服务状态
+ - command (str): 启动命令
+ - args (List[str]): 命令参数
+ - env (Dict[str, str]): 环境变量
+ - pid (Optional[int]): 进程ID
+ - uptime (float): 运行时间
+ - tools (List[Dict]): 可用工具列表
+ - last_error (Optional[str]): 最后错误信息
+
+ Raises:
+ ServiceNotFoundError: 服务不存在
+
+ Example:
+ >>> info = store.get_service_info("filesystem")
+ >>> print(f"工具数量: {len(info['tools'])}")
+ """
+```
+
+#### 工具管理方法
+
+##### list_tools()
+
+```python
+def list_tools(self, service_name: Optional[str] = None) -> List[Dict[str, Any]]:
+ """
+ 列出可用工具
+
+ Args:
+ service_name: 可选,指定服务名称以过滤工具
+
+ Returns:
+ List[Dict]: 工具列表,每个工具包含以下字段:
+ - name (str): 工具名称
+ - description (str): 工具描述
+ - service_name (str): 所属服务
+ - parameters (Dict): 参数定义
+ - returns (Dict): 返回值定义
+
+ Example:
+ >>> tools = store.list_tools()
+ >>> filesystem_tools = store.list_tools(service_name="filesystem")
+ """
+```
+
+##### get_tool_info()
+
+```python
+def get_tool_info(self, tool_name: str, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 获取工具详细信息
+
+ Args:
+ tool_name: 工具名称
+ service_name: 可选,服务名称
+
+ Returns:
+ Dict: 工具信息,包含以下字段:
+ - name (str): 工具名称
+ - description (str): 工具描述
+ - service_name (str): 所属服务
+ - parameters (Dict): 参数定义
+ - returns (Dict): 返回值定义
+ - examples (List[Dict]): 使用示例
+
+ Raises:
+ ToolNotFoundError: 工具不存在
+
+ Example:
+ >>> info = store.get_tool_info("read_file")
+ >>> print(info['description'])
+ """
+```
+
+##### call_tool()
+
+```python
+def call_tool(self, tool_name: str, arguments: Dict[str, Any], **options) -> Any:
+ """
+ 调用指定工具
+
+ Args:
+ tool_name: 工具名称
+ arguments: 工具参数
+ **options: 额外选项
+ - timeout (float): 调用超时时间
+ - retry_count (int): 重试次数
+ - service_name (str): 指定服务名称
+
+ Returns:
+ Any: 工具执行结果
+
+ Raises:
+ ToolNotFoundError: 工具不存在
+ ToolExecutionError: 工具执行失败
+ ToolTimeoutError: 工具执行超时
+
+ Example:
+ >>> result = store.call_tool("read_file", {"path": "/tmp/test.txt"})
+ >>> print(result)
+ """
+```
+
+##### use_tool()
+
+```python
+def use_tool(self, tool_name: str, **kwargs) -> Any:
+ """
+ 便捷的工具调用方法
+
+ Args:
+ tool_name: 工具名称
+ **kwargs: 工具参数(作为关键字参数)
+
+ Returns:
+ Any: 工具执行结果
+
+ Example:
+ >>> content = store.use_tool("read_file", path="/tmp/test.txt")
+ >>> store.use_tool("write_file", path="/tmp/output.txt", content="Hello")
+ """
+```
+
+##### batch_call()
+
+```python
+def batch_call(self, calls: List[Dict[str, Any]], parallel: bool = True, max_workers: Optional[int] = None) -> List[Dict[str, Any]]:
+ """
+ 批量调用工具
+
+ Args:
+ calls: 调用列表,每个调用包含:
+ - tool_name (str): 工具名称
+ - arguments (Dict): 工具参数
+ parallel: 是否并行执行
+ max_workers: 最大工作线程数
+
+ Returns:
+ List[Dict]: 执行结果列表,每个结果包含:
+ - success (bool): 是否成功
+ - result (Any): 执行结果
+ - error (Optional[str]): 错误信息
+ - execution_time (float): 执行时间
+
+ Example:
+ >>> calls = [
+ ... {"tool_name": "read_file", "arguments": {"path": "/tmp/file1.txt"}},
+ ... {"tool_name": "read_file", "arguments": {"path": "/tmp/file2.txt"}}
+ ... ]
+ >>> results = store.batch_call(calls)
+ """
+```
+
+#### 健康检查方法
+
+##### check_services()
+
+```python
+def check_services(self, service_names: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
+ """
+ 检查服务健康状态
+
+ Args:
+ service_names: 可选,指定要检查的服务名称列表
+
+ Returns:
+ Dict: 健康检查结果,格式为:
+ {
+ "service_name": {
+ "healthy": bool,
+ "status": str,
+ "response_time": float,
+ "last_check": float,
+ "error": Optional[str]
+ }
+ }
+
+ Example:
+ >>> health = store.check_services()
+ >>> print(health["filesystem"]["healthy"])
+ True
+ """
+```
+
+## 🔧 配置类
+
+### MCPStoreConfig
+
+```python
+class MCPStoreConfig:
+ """MCPStore 配置类"""
+
+ def __init__(self, **kwargs):
+ """
+ 初始化配置
+
+ Args:
+ data_dir (str): 数据目录
+ log_level (str): 日志级别
+ timeout (float): 默认超时时间
+ max_connections (int): 最大连接数
+ retry_count (int): 重试次数
+ cache_size (int): 缓存大小
+ enable_monitoring (bool): 启用监控
+ """
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+
+ @classmethod
+ def from_dict(cls, config_dict: Dict[str, Any]) -> 'MCPStoreConfig':
+ """从字典创建配置"""
+
+ @classmethod
+ def from_file(cls, config_file: str) -> 'MCPStoreConfig':
+ """从文件加载配置"""
+```
+
+## ⚠️ 异常类
+
+### 基础异常
+
+```python
+class MCPStoreError(Exception):
+ """MCPStore 基础异常"""
+
+ def __init__(self, message: str, error_code: Optional[str] = None, details: Optional[Dict] = None):
+ self.message = message
+ self.error_code = error_code
+ self.details = details or {}
+```
+
+### 服务相关异常
+
+```python
+class ServiceError(MCPStoreError):
+ """服务相关异常基类"""
+
+class ServiceNotFoundError(ServiceError):
+ """服务不存在异常"""
+
+class ServiceStartError(ServiceError):
+ """服务启动异常"""
+
+class ServiceStopError(ServiceError):
+ """服务停止异常"""
+
+class ServiceTimeoutError(ServiceError):
+ """服务超时异常"""
+
+class ServiceRegistrationError(ServiceError):
+ """服务注册异常"""
+```
+
+### 工具相关异常
+
+```python
+class ToolError(MCPStoreError):
+ """工具相关异常基类"""
+
+class ToolNotFoundError(ToolError):
+ """工具不存在异常"""
+
+class ToolExecutionError(ToolError):
+ """工具执行异常"""
+
+class ToolTimeoutError(ToolError):
+ """工具超时异常"""
+```
+
+### 配置相关异常
+
+```python
+class ConfigurationError(MCPStoreError):
+ """配置异常"""
+
+class InvalidConfigError(ConfigurationError):
+ """无效配置异常"""
+
+class ConfigFileNotFoundError(ConfigurationError):
+ """配置文件不存在异常"""
+```
+
+## 📊 数据类型
+
+### 服务状态枚举
+
+```python
+from enum import Enum
+
+class ServiceStatus(Enum):
+ NOT_STARTED = "not_started"
+ STARTING = "starting"
+ RUNNING = "running"
+ STOPPING = "stopping"
+ STOPPED = "stopped"
+ ERROR = "error"
+ UNKNOWN = "unknown"
+```
+
+### 工具调用结果
+
+```python
+from typing import TypedDict, Optional, Any
+
+class ToolCallResult(TypedDict):
+ success: bool
+ result: Optional[Any]
+ error: Optional[str]
+ execution_time: float
+ tool_name: str
+ arguments: Dict[str, Any]
+```
+
+### 服务信息
+
+```python
+class ServiceInfo(TypedDict):
+ name: str
+ status: str
+ command: str
+ args: List[str]
+ env: Dict[str, str]
+ pid: Optional[int]
+ uptime: float
+ tools: List[Dict[str, Any]]
+ last_error: Optional[str]
+```
+
+## 🔗 常量
+
+```python
+# 默认配置
+DEFAULT_TIMEOUT = 30.0
+DEFAULT_RETRY_COUNT = 3
+DEFAULT_MAX_CONNECTIONS = 10
+DEFAULT_CACHE_SIZE = 1000
+
+# 状态常量
+SERVICE_STATUS_RUNNING = "running"
+SERVICE_STATUS_STOPPED = "stopped"
+SERVICE_STATUS_ERROR = "error"
+
+# 错误代码
+ERROR_SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
+ERROR_TOOL_NOT_FOUND = "TOOL_NOT_FOUND"
+ERROR_EXECUTION_FAILED = "EXECUTION_FAILED"
+ERROR_TIMEOUT = "TIMEOUT"
+ERROR_CONFIGURATION = "CONFIGURATION_ERROR"
+```
+
+## 📚 使用示例
+
+### 完整 API 使用示例
+
+```python
+from mcpstore import MCPStore
+from mcpstore.exceptions import ServiceError, ToolError
+
+# 初始化
+store = MCPStore(config={
+ "timeout": 60,
+ "max_connections": 15,
+ "log_level": "INFO"
+})
+
+try:
+ # 添加服务
+ store.add_service({
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ })
+
+ # 启动服务
+ store.start_service("filesystem")
+
+ # 检查状态
+ status = store.get_service_status("filesystem")
+ print(f"服务状态: {status}")
+
+ # 列出工具
+ tools = store.list_tools()
+ print(f"可用工具: {[t['name'] for t in tools]}")
+
+ # 调用工具
+ result = store.call_tool("read_file", {"path": "/tmp/test.txt"})
+ print(f"文件内容: {result}")
+
+ # 批量调用
+ calls = [
+ {"tool_name": "list_directory", "arguments": {"path": "/tmp"}},
+ {"tool_name": "get_file_info", "arguments": {"path": "/tmp/test.txt"}}
+ ]
+ results = store.batch_call(calls)
+
+ # 健康检查
+ health = store.check_services()
+ print(f"健康状态: {health}")
+
+except ServiceError as e:
+ print(f"服务错误: {e.message}")
+except ToolError as e:
+ print(f"工具错误: {e.message}")
+except Exception as e:
+ print(f"未知错误: {e}")
+```
+
+## 🔗 相关文档
+
+- [快速开始](../quickstart.md)
+- [配置项说明](../api/cache-config.md)
+- [服务管理指南](../services/overview.md)
+- [工具使用指南](../tools/overview.md)
+
+---
+
+**更新时间**: 2025-01-09
+**版本**: 1.0.0
diff --git a/docs/docs/assets/extra.css b/docs/docs/assets/extra.css
new file mode 100644
index 00000000..3c7752d9
--- /dev/null
+++ b/docs/docs/assets/extra.css
@@ -0,0 +1,10 @@
+/* Hide site title text next to the logo but keep flex container for right alignment */
+.md-header__title .md-ellipsis {
+ display: none;
+}
+
+/* Optional: remove extra gap after the logo */
+.md-header__title {
+ margin-left: 0;
+}
+
diff --git a/docs/docs/assets/logo.svg b/docs/docs/assets/logo.svg
new file mode 100644
index 00000000..5a3b4f11
--- /dev/null
+++ b/docs/docs/assets/logo.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/docs/docs/assets/logo_w.svg b/docs/docs/assets/logo_w.svg
new file mode 100644
index 00000000..ef3d047c
--- /dev/null
+++ b/docs/docs/assets/logo_w.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/docs/cli/commands.md b/docs/docs/cli/commands.md
new file mode 100644
index 00000000..f1bef36c
--- /dev/null
+++ b/docs/docs/cli/commands.md
@@ -0,0 +1,114 @@
+# 命令参考
+
+MCPStore CLI 提供的所有命令的详细参考文档。
+
+## 命令概览
+
+| 命令 | 功能 | 用途 |
+|------|------|------|
+| `run` | 运行服务 | 启动 API 服务器等 |
+| `test` | 运行测试 | 执行各种测试套件 |
+| `config` | 配置管理 | 管理配置文件 |
+| `version` | 版本信息 | 显示版本号 |
+
+## run - 运行服务
+
+启动 MCPStore 相关服务。
+
+### 语法
+
+```bash
+mcpstore run SERVICE [OPTIONS]
+```
+
+### 参数
+
+#### 位置参数
+
+- `SERVICE`: 要运行的服务名称
+ - `api`: 启动 MCPStore API 服务器
+
+#### 选项参数
+
+| 选项 | 短选项 | 类型 | 默认值 | 描述 |
+|------|--------|------|--------|------|
+| `--host` | `-h` | str | `0.0.0.0` | 绑定的主机地址 |
+| `--port` | `-p` | int | `18200` | 绑定的端口号 |
+| `--reload` | `-r` | bool | `False` | 启用自动重载(开发模式) |
+| `--log-level` | `-l` | str | `info` | 日志级别 |
+
+#### 日志级别选项
+
+- `critical`: 只显示严重错误
+- `error`: 显示错误信息
+- `warning`: 显示警告信息
+- `info`: 显示一般信息(默认)
+- `debug`: 显示调试信息
+
+### 使用示例
+
+#### 基本用法
+
+```bash
+# 使用默认配置启动 API 服务器
+mcpstore run api
+```
+
+**输出**:
+```
+🚀 Starting MCPStore API Server...
+ Host: 0.0.0.0:18200
+ Press Ctrl+C to stop
+
+INFO: Started server process [12345]
+INFO: Waiting for application startup.
+INFO: Application startup complete.
+INFO: Uvicorn running on http://0.0.0.0:18200 (Press CTRL+C to quit)
+```
+
+#### 自定义配置
+
+```bash
+# 自定义主机和端口
+mcpstore run api --host 127.0.0.1 --port 8080
+
+# 开发模式(自动重载)
+mcpstore run api --reload --log-level debug
+
+# 生产模式(最小日志)
+mcpstore run api --host 0.0.0.0 --port 18200 --log-level warning
+```
+
+#### 开发环境配置
+
+```bash
+# 完整的开发环境配置
+mcpstore run api \
+ --host 127.0.0.1 \
+ --port 8080 \
+ --reload \
+ --log-level debug
+```
+
+**输出**:
+```
+🚀 Starting MCPStore API Server...
+ Host: 127.0.0.1:8080
+ Mode: Development (auto-reload enabled)
+ Press Ctrl+C to stop
+
+INFO: Will watch for changes in these directories: ['/path/to/mcpstore']
+INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
+INFO: Started reloader process [12345] using StatReload
+INFO: Started server process [12346]
+```
+
+#### 选项参数
+
+| 选项 | 短选项 | 类型 | 默认值 | 描述 |
+|------|--------|------|--------|------|
+| `--host` | | str | `localhost` | API 服务器主机 |
+| `--port` | | int | `18611` | API 服务器端口 |
+| `--verbose` | `-v` | bool | `False` | 详细输出 |
+| `--performance` | `-p` | bool | `False` | 包含性能测试 |
+| `--max-concurrent` | | int | `10` | 性能测试最大并发数 |
diff --git a/docs/docs/hub/services.md b/docs/docs/hub/services.md
new file mode 100644
index 00000000..ee5afbb9
--- /dev/null
+++ b/docs/docs/hub/services.md
@@ -0,0 +1,5 @@
+## hub_services()
+
+Placeholder page for store.for_store().hub_services().
+
+
diff --git a/docs/docs/index.md b/docs/docs/index.md
new file mode 100644
index 00000000..b7ce52f8
--- /dev/null
+++ b/docs/docs/index.md
@@ -0,0 +1,78 @@
+
+
+
+
+---
+
+### mcpstore 是什么?
+
+mcpstore 是面向开发者的开箱即用的 MCP 服务编排层:用一个 Store 统一管理服务,并将 MCP 适配给 AI 框架`LangChain等`使用。
+
+### 简单示例
+
+首先只需要需要初始化一个store
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store()
+```
+
+现在就有了一个 `store`,后续只需要围绕这个`store`去添加或者操作你的服务,`store` 会维护和管理这些 MCP 服务。
+
+#### 给store添加第一个服务
+
+```python
+#在上面的代码下面加入
+store.for_store().add_service({"mcpServers": {"mcpstore_wiki": {"url": "https://www.mcpstore.wiki/mcp"}}})
+store.for_store().wait_service("mcpstore_wiki")
+```
+
+通过add方法便捷添加服务,add_service方法支持多种mcp服务配置格式,主流的mcp配置格式都可以直接传入。wait方法可选,是否同步等待服务就绪。
+
+#### 将mcp适配转为langchain需要的对象
+
+```python
+tools = store.for_store().for_langchain().list_tools()
+print("loaded langchain tools:", len(tools))
+```
+
+简单链上即可直观的将mcp适配为langchain直接使用的tools列表
+
+##### 框架适配
+
+会逐渐支持更多的框架
+
+| 已支持框架 | 获取工具 |
+| --- | --- |
+| LangChain | `tools = store.for_store().for_langchain().list_tools()` |
+| LangGraph | `tools = store.for_store().for_langgraph().list_tools()` |
+| AutoGen | `tools = store.for_store().for_autogen().list_tools()` |
+| CrewAI | `tools = store.for_store().for_crewai().list_tools()` |
+| LlamaIndex | `tools = store.for_store().for_llamaindex().list_tools()` |
+
+#### 现在就可以正常的使用langchain了
+
+```python
+#添加上面的代码
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+llm = ChatOpenAI(
+ temperature=0,
+ model="deepseek-chat",
+ api_key="sk-*****",
+ base_url="https://api.deepseek.com"
+)
+agent = create_agent(model=llm, tools=tools, system_prompt="你是一个助手,回答的时候带上表情")
+events = agent.invoke({"messages": [{"role": "user", "content": "mcpstore怎么添加服务?"}]})
+print(events)
+```
+
+## 下一步
+
+- [快速上手](quickstart.md) - 30秒快速上手 MCPStore
+- [服务管理](services/overview.md) - 了解如何管理 MCP 服务
+- [工具管理](tools/overview.md) - 学习如何使用工具
+
+---
+
+**准备好开始了吗?** 让我们从 [快速上手指南](quickstart.md) 开始吧!
diff --git a/docs/docs/integrations/overview.md b/docs/docs/integrations/overview.md
new file mode 100644
index 00000000..c4339e77
--- /dev/null
+++ b/docs/docs/integrations/overview.md
@@ -0,0 +1,253 @@
+## 框架集成概览
+
+MCPStore 提供了与主流 AI 框架的无缝集成,让你可以轻松地在各种 AI 开发框架中使用 MCP 工具。
+
+## 支持的框架
+
+MCPStore 目前支持以下主流 AI 框架:
+
+| 框架 | 状态 | 集成方式 |
+|------|------|----------|
+| **LangChain** | 完全支持 | `for_langchain()` |
+| **LlamaIndex** | 完全支持 | `for_llamaindex()` |
+| **CrewAI** | 完全支持 | `for_crewai()` |
+| **LangGraph** | 完全支持 | `for_langgraph()` |
+| **AutoGen** | 完全支持 | `for_autogen()` |
+| **Semantic Kernel** | 完全支持 | `for_semantic_kernel()` |
+
+---
+
+## 快速开始
+
+### 通用集成模式
+
+所有框架集成都遵循相同的模式:
+
+```python
+from mcpstore import MCPStore
+
+# 1. 初始化 Store
+store = MCPStore.setup_store()
+
+# 2. 添加 MCP 服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 3. 等待服务就绪
+store.for_store().wait_service("weather")
+
+# 4. 转换为目标框架的工具格式
+# LangChain
+langchain_tools = store.for_store().for_langchain().list_tools()
+
+# LlamaIndex
+llamaindex_tools = store.for_store().for_llamaindex().list_tools()
+
+# CrewAI
+crewai_tools = store.for_store().for_crewai().list_tools()
+
+# ... 其他框架类似
+```
+
+---
+
+## LangChain 集成示例
+
+### 基础集成
+
+```python
+from mcpstore import MCPStore
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_openai import ChatOpenAI
+
+# 初始化 MCPStore
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_agent("agent1").add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 等待服务就绪
+store.for_agent("agent1").wait_service("weather")
+
+# 转换为 LangChain 工具
+lc_tools = store.for_agent("agent1").for_langchain().list_tools()
+
+# 创建 LLM
+llm = ChatOpenAI(temperature=0, model="gpt-4")
+
+# 创建 Prompt
+prompt = ChatPromptTemplate.from_messages([
+ ("system", "你是一个助手"),
+ ("human", "{input}"),
+ ("placeholder", "{agent_scratchpad}"),
+])
+
+# 创建 Agent
+agent = create_tool_calling_agent(llm, lc_tools, prompt)
+agent_executor = AgentExecutor(agent=agent, tools=lc_tools, verbose=True)
+
+# 执行查询
+response = agent_executor.invoke({"input": "北京的天气怎么样?"})
+print(response.get('output'))
+```
+
+### 设置 return_direct
+
+MCPStore 支持为工具设置 `return_direct` 标记:
+
+```python
+# 设置工具重定向(LangChain return_direct)
+store.for_agent("agent1").find_tool("get_weather").set_redirect(True)
+
+# 转换为 LangChain 工具时,return_direct 会自动应用
+lc_tools = store.for_agent("agent1").for_langchain().list_tools()
+
+# 验证
+for tool in lc_tools:
+ if tool.name == "get_weather":
+ print(f"return_direct: {tool.return_direct}") # True
+```
+
+更多使用示例可参考本页的代码片段和 [工具概览](../tools/overview.md) 中的指南。
+
+---
+
+## 集成特性
+
+### 统一接口
+所有框架集成都使用相同的 API 模式:
+
+```python
+# 统一的调用方式
+framework_tools = store.for_store().for_{framework}().list_tools()
+```
+
+### 自动转换
+MCPStore 会自动将 MCP 工具转换为目标框架的工具格式:
+
+- **LangChain**: 转换为 `StructuredTool`
+- **LlamaIndex**: 转换为 `FunctionTool`
+- **CrewAI**: 转换为 CrewAI 工具格式
+- **LangGraph**: 转换为 LangGraph 工具格式
+- **AutoGen**: 转换为 AutoGen 工具格式
+- **Semantic Kernel**: 转换为 SK 函数
+
+### 保持同步
+工具配置(如 `return_direct`)会自动同步到转换后的框架工具。
+
+### Agent 隔离
+每个 Agent 可以有独立的服务和工具集成:
+
+```python
+# Agent1 使用天气服务
+store.for_agent("agent1").add_service({...})
+agent1_tools = store.for_agent("agent1").for_langchain().list_tools()
+
+# Agent2 使用搜索服务
+store.for_agent("agent2").add_service({...})
+agent2_tools = store.for_agent("agent2").for_langchain().list_tools()
+
+# 两个 Agent 的工具完全隔离
+```
+
+---
+
+## 集成对比
+
+| 特性 | LangChain | LlamaIndex | CrewAI | LangGraph | AutoGen | SK |
+|------|-----------|------------|--------|-----------|---------|-----|
+| 工具转换 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
+| return_direct | 支持 | 不支持 | 不支持 | 支持 | 不支持 | 不支持 |
+| 异步支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
+| Agent 隔离 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
+| 工具配置 | 支持 | 部分 | 部分 | 支持 | 部分 | 部分 |
+
+
+
+## 最佳实践
+
+### 使用 Agent 模式进行隔离
+
+```python
+# 为不同用途创建独立的 Agent
+research_tools = store.for_agent("research").for_langchain().list_tools()
+writing_tools = store.for_agent("writing").for_langchain().list_tools()
+```
+
+### 设置合适的 return_direct
+
+```python
+# 查询类工具适合 return_direct
+store.for_agent("agent1").find_tool("search").set_redirect(True)
+
+# 需要 Agent 解释的工具不设置
+# store.for_agent("agent1").find_tool("analyze").set_redirect(False)
+```
+
+### 等待服务就绪
+
+```python
+# 在转换工具前确保服务就绪
+store.for_store().wait_service("service_name", timeout=30.0)
+tools = store.for_store().for_langchain().list_tools()
+```
+
+### 错误处理
+
+```python
+try:
+ tools = store.for_store().for_langchain().list_tools()
+ if not tools:
+ print("警告:没有可用工具")
+except Exception as e:
+ print(f"工具转换失败: {e}")
+```
+
+---
+
+## 常见问题
+
+### Q: 可以同时在多个框架中使用同一个 Store 吗?
+**A**: 可以!MCPStore 支持同时为多个框架提供工具:
+
+```python
+store = MCPStore.setup_store()
+store.for_store().add_service({...})
+
+# 同时使用
+lc_tools = store.for_store().for_langchain().list_tools()
+li_tools = store.for_store().for_llamaindex().list_tools()
+```
+
+### Q: 框架集成会影响性能吗?
+**A**: 不会。工具转换是轻量级操作,不会显著影响性能。
+
+### Q: 如何在框架中使用会话功能?
+**A**: 部分框架支持会话。请参考各框架的详细文档。
+
+### Q: 集成后如何调试?
+**A**: 启用调试模式:
+
+```python
+store = MCPStore.setup_store(debug=True)
+```
+
+---
+
+## 相关文档
+
+- [工具管理概览](../tools/overview.md) - 了解工具管理基础
+- [服务管理概览](../services/overview.md) - 了解服务管理
+- [快速上手指南](../getting-started/quickstart.md) - 快速入门
+
+---
+
+选择你使用的框架,查看详细的集成文档。
diff --git a/docs/docs/prompts/get-prompt.md b/docs/docs/prompts/get-prompt.md
new file mode 100644
index 00000000..4b5b2bbd
--- /dev/null
+++ b/docs/docs/prompts/get-prompt.md
@@ -0,0 +1,5 @@
+## get_prompt()
+
+Placeholder page for store.for_store().get_prompt().
+
+
diff --git a/docs/docs/prompts/list-prompts.md b/docs/docs/prompts/list-prompts.md
new file mode 100644
index 00000000..7167b704
--- /dev/null
+++ b/docs/docs/prompts/list-prompts.md
@@ -0,0 +1,5 @@
+## list_prompts()
+
+Placeholder page for store.for_store().list_prompts().
+
+
diff --git a/docs/docs/quickstart.md b/docs/docs/quickstart.md
new file mode 100644
index 00000000..b7ce52f8
--- /dev/null
+++ b/docs/docs/quickstart.md
@@ -0,0 +1,78 @@
+
+
+
+
+---
+
+### mcpstore 是什么?
+
+mcpstore 是面向开发者的开箱即用的 MCP 服务编排层:用一个 Store 统一管理服务,并将 MCP 适配给 AI 框架`LangChain等`使用。
+
+### 简单示例
+
+首先只需要需要初始化一个store
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store()
+```
+
+现在就有了一个 `store`,后续只需要围绕这个`store`去添加或者操作你的服务,`store` 会维护和管理这些 MCP 服务。
+
+#### 给store添加第一个服务
+
+```python
+#在上面的代码下面加入
+store.for_store().add_service({"mcpServers": {"mcpstore_wiki": {"url": "https://www.mcpstore.wiki/mcp"}}})
+store.for_store().wait_service("mcpstore_wiki")
+```
+
+通过add方法便捷添加服务,add_service方法支持多种mcp服务配置格式,主流的mcp配置格式都可以直接传入。wait方法可选,是否同步等待服务就绪。
+
+#### 将mcp适配转为langchain需要的对象
+
+```python
+tools = store.for_store().for_langchain().list_tools()
+print("loaded langchain tools:", len(tools))
+```
+
+简单链上即可直观的将mcp适配为langchain直接使用的tools列表
+
+##### 框架适配
+
+会逐渐支持更多的框架
+
+| 已支持框架 | 获取工具 |
+| --- | --- |
+| LangChain | `tools = store.for_store().for_langchain().list_tools()` |
+| LangGraph | `tools = store.for_store().for_langgraph().list_tools()` |
+| AutoGen | `tools = store.for_store().for_autogen().list_tools()` |
+| CrewAI | `tools = store.for_store().for_crewai().list_tools()` |
+| LlamaIndex | `tools = store.for_store().for_llamaindex().list_tools()` |
+
+#### 现在就可以正常的使用langchain了
+
+```python
+#添加上面的代码
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+llm = ChatOpenAI(
+ temperature=0,
+ model="deepseek-chat",
+ api_key="sk-*****",
+ base_url="https://api.deepseek.com"
+)
+agent = create_agent(model=llm, tools=tools, system_prompt="你是一个助手,回答的时候带上表情")
+events = agent.invoke({"messages": [{"role": "user", "content": "mcpstore怎么添加服务?"}]})
+print(events)
+```
+
+## 下一步
+
+- [快速上手](quickstart.md) - 30秒快速上手 MCPStore
+- [服务管理](services/overview.md) - 了解如何管理 MCP 服务
+- [工具管理](tools/overview.md) - 学习如何使用工具
+
+---
+
+**准备好开始了吗?** 让我们从 [快速上手指南](quickstart.md) 开始吧!
diff --git a/docs/docs/resources/list-resources.md b/docs/docs/resources/list-resources.md
new file mode 100644
index 00000000..3a80da3d
--- /dev/null
+++ b/docs/docs/resources/list-resources.md
@@ -0,0 +1,5 @@
+## list_resources()
+
+Placeholder page for store.for_store().list_resources().
+
+
diff --git a/docs/docs/resources/read-resource.md b/docs/docs/resources/read-resource.md
new file mode 100644
index 00000000..644f6203
--- /dev/null
+++ b/docs/docs/resources/read-resource.md
@@ -0,0 +1,5 @@
+## read_resource()
+
+Placeholder page for store.for_store().read_resource().
+
+
diff --git a/docs/docs/services/add-service.md b/docs/docs/services/add-service.md
new file mode 100644
index 00000000..7665c1dc
--- /dev/null
+++ b/docs/docs/services/add-service.md
@@ -0,0 +1,160 @@
+## add_service - 服务注册
+
+
+
+如何通过 MCPStore 注册服务
+
+### SDK
+
+同步:
+ - `store.for_store().add_service(config=..., ...) -> bool`
+
+异步:
+ - `await store.for_store().add_service_async(config=..., ...) -> bool`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|------------|----------|------------------------------------------|
+| `config` | dict/str | 服务配置,支持多种结构。 |
+| `json_file`| str | 从 JSON 文件加载配置(若提供则优先读取)。|
+| `headers` | dict | 用于设置认证相关请求头。 |
+
+### config 参数 与 json_file 参数
+
+config参数支持这些格式:
+
+单个远程服务
+```python
+cfg = {
+ "name": "mcpstore_wiki",
+ "url": "https://www.mcpstore.wiki/mcp"
+}
+```
+
+单个本地服务
+```python
+cfg = {
+ "name": "assistant",
+ "command": "python",
+ "args": ["./assistant_server.py"],
+ "env": {"DEBUG": "true"}
+}
+```
+
+mcpServers JSON 格式(兼容 Cursor 等 IDE)
+```python
+cfg = {
+ "mcpServers": {
+ "weather": {"url": "..."},
+ "assistant": {"command": "..."}
+ }
+}
+ ```
+
+宽字典
+```python
+cfg = {
+ "weather": {"url": "https://weather.example.com/mcp"},
+ "assistant": {"command": "python", "args": ["./assistant.py"]}
+}
+```
+
+批量config列表
+```python
+cfg = [
+ {"name": "weather", "url": "https://weather.example.com/mcp"},
+ {"name": "assistant", "command": "python", "args": ["./assistant.py"]},
+ {"name": "calculator", "command": "node", "args": ["calc.js"]}
+]
+```
+
+配置好上述的cfg然后执行add_service()即可
+```python
+# 字典与字符串 `str(cfg)` 都可以
+store.for_store().add_service(cfg)
+```
+
+或者可以使用json_file参数 直接指定json文件
+```python
+cfg = "/home/work/mcp.json"
+store.for_store().add_service(json_file=cfg)
+```
+
+#### config传输类型判断
+
+可以指定传输类型 未指定时将自动推断。
+```python
+# 默认推断为 streamable-http
+cfg = {"name": "api1", "url": "https://api.example.com/mcp"}
+store.for_store().add_service(cfg)
+
+# URL 包含 /sse → 推断为 sse
+cfg = {"name": "api2", "url": "https://api.example.com/sse"}
+store.for_store().add_service(cfg)
+```
+
+
+### headers参数
+使用 Bearer Token
+```python
+headers = {"Authorization": "Bearer "}
+```
+使用 API Key
+```python
+headers = {"X-API-Key": ""}
+ ```
+自定义请求头
+```python
+headers = {"Authorization": "Bearer ", "X-Custom-Header": "value"}
+```
+在添加服务时配置:
+```python
+store.for_store().add_service(config=cfg, headers=headers)
+```
+如果需要后续更新服务的时候更新请求头。参考todo链接到更新服务的鉴权部分
+
+
+
+
+### 视角
+通过 `for_store()` 注册的服务名为全局名称,在全局空间可见,可以想象是为你的store添加的服务,后续的Agent可以直接通过服务名添加或者查询。
+
+
+### 常用配合:等待
+add_service 为“注册并触发初始化”操作 不阻塞等待连接与健康检查完成 常搭配 `wait_service()` 使用
+
+```python
+# 添加操作通常是ms级完成
+store.for_store().add_service({"name": "weather", "url": "..."})
+# 添加后可以等待服务就绪
+store.for_store().wait_service("weather")
+```
+
+也可以等待服务收敛到指定状态与设置超时等待时间(本地服务一般需要更长的等待收敛时间)
+
+使用:`store.for_store().wait_service("service_name", status=["healthy", "warning",....], timeout=30)`。
+
+更多细节详见 `wait_service`(专题 TODO:添加链接跳转)。
+
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|-----------------------|--------------------------------------------------------------------------------------------|
+| 注册服务 | `store.for_store().add_service(config=..., ...)` |
+| 等待服务 | `store.for_store().wait_service(name, status="healthy", timeout=...)` |
+| 等待服务实现目标状态 | `store.for_store().wait_service(...)` |
+| 更新服务 | `store.for_store().update_service(...)` |
+| Patch 更新服务 | `store.for_store().patch_service(...)` |
+| 删除服务 | `store.for_store().delete_service(...)` |
+| 重启服务 | `store.for_store().restart_service(...)` |
+| 查看配置 | `store.for_store().show_config(...)` |
+| 重置配置 | `store.for_store().reset_config(...)` |
+| 获取服务信息 | `store.for_store().get_service_info(...)` |
+| 获取服务状态 | `store.for_store().get_service_status(...)` |
+| Agent 注册 | store.for_agent(id).add_service() |
+| Agent 等待 | store.for_agent(id).wait_service() |
+
+
diff --git a/docs/docs/services/check-health.md b/docs/docs/services/check-health.md
new file mode 100644
index 00000000..28538e9b
--- /dev/null
+++ b/docs/docs/services/check-health.md
@@ -0,0 +1,280 @@
+## check_health - 健康检查(ServiceProxy)
+
+检查单个服务的健康状态(ServiceProxy 级别)。
+
+### SDK
+
+调用方式:ServiceProxy 方法
+
+获取方式:
+ - `svc = store.for_store().find_service(name)`
+ - `svc = store.for_agent(id).find_service(name)`
+
+同步:
+ - `svc.check_health() -> Dict[str, Any]`
+
+异步:
+ - `await svc.check_health_async() -> Dict[str, Any]`
+
+## 参数
+
+| 参数名 | 类型 | 默认值 | 描述 |
+|--------|------|--------|------|
+| 无参数 | - | - | 该方法不需要参数 |
+
+## 返回值
+
+返回简化的健康状态摘要字典:
+
+```python
+{
+ "healthy": bool, # 是否健康
+ "status": str, # 状态字符串
+ "response_time": float, # 响应时间(秒)
+ "last_check": str # 最后检查时间(ISO格式)
+}
+```
+
+## 使用示例
+
+### Store级别健康检查
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("weather")
+
+# 查找服务
+svc = store.for_store().find_service("weather")
+
+# 检查健康状态
+health = svc.check_health()
+print(f"健康状态: {health}")
+
+if health["healthy"]:
+ print(f"服务健康 (响应时间: {health['response_time']:.3f}秒)")
+else:
+ print(f"服务异常: {health['status']}")
+```
+
+### Agent级别健康检查
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# Agent级别添加服务
+store.for_agent("agent1").add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 等待服务就绪
+store.for_agent("agent1").wait_service("weather")
+
+# 查找服务
+svc = store.for_agent("agent1").find_service("weather")
+
+# 检查健康状态
+health = svc.check_health()
+print(f"Agent服务健康: {health}")
+```
+
+### 持续健康监控
+
+```python
+import time
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+svc = store.for_store().find_service("weather")
+
+# 持续监控健康状态
+print("开始健康监控...")
+for i in range(5):
+ health = svc.check_health()
+ print(f"[检查 {i+1}] 状态: {health['status']}, 响应时间: {health['response_time']:.3f}秒")
+ time.sleep(3)
+```
+
+### 批量健康检查
+
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 添加多个服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"},
+ "calculator": {"command": "python", "args": ["calc.py"]}
+ }
+})
+
+# 等待所有服务
+store.for_store().wait_service("weather")
+store.for_store().wait_service("calculator")
+
+# 批量健康检查
+service_names = ["weather", "calculator"]
+health_report = {}
+
+print("服务健康报告")
+print("=" * 50)
+
+for name in service_names:
+ svc = store.for_store().find_service(name)
+ health = svc.check_health()
+ health_report[name] = health
+
+ print(f"{name}:")
+ print(f" 状态: {health['status']}")
+ print(f" 响应时间: {health['response_time']:.3f}秒")
+ print(f" 最后检查: {health['last_check']}")
+ print()
+
+# 统计
+healthy_count = sum(1 for h in health_report.values() if h["healthy"])
+print(f"健康服务: {healthy_count}/{len(service_names)}")
+```
+
+### 响应时间分析
+
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+svc = store.for_store().find_service("weather")
+
+# 多次检查,计算平均响应时间
+response_times = []
+
+for _ in range(10):
+ health = svc.check_health()
+ response_times.append(health['response_time'])
+
+avg_response = sum(response_times) / len(response_times)
+max_response = max(response_times)
+min_response = min(response_times)
+
+print(f"响应时间分析 (10次检查)")
+print(f" 平均响应时间: {avg_response:.3f}秒")
+print(f" 最大响应时间: {max_response:.3f}秒")
+print(f" 最小响应时间: {min_response:.3f}秒")
+```
+
+### 异常处理示例
+
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+svc = store.for_store().find_service("weather")
+
+try:
+ health = svc.check_health()
+
+ if not health["healthy"]:
+ print(f"服务不健康: {health['status']}")
+
+ # 尝试重启
+ print("尝试重启服务...")
+ svc.restart_service()
+
+ # 再次检查
+ import time
+ time.sleep(2)
+ health = svc.check_health()
+
+ if health["healthy"]:
+ print("服务已恢复健康")
+ else:
+ print("服务仍然异常")
+
+except Exception as e:
+ print(f"健康检查失败: {e}")
+```
+
+## 返回字段说明
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `healthy` | bool | 服务是否健康,true表示健康 |
+| `status` | str | 服务状态(HEALTHY/WARNING/RECONNECTING等)|
+| `response_time` | float | 最近一次健康检查的响应时间(秒)|
+| `last_check` | str | 最后一次检查的时间戳(ISO 8601格式)|
+
+## 与 check_services() 的区别
+
+| 对比项 | check_health() | check_services() |
+|--------|----------------|------------------|
+| **调用方式** | ServiceProxy方法 | Context方法 |
+| **检查范围** | 单个服务 | 所有服务 |
+| **返回格式** | 简化摘要 | 详细字典 |
+| **使用场景** | 针对性检查 | 全局健康检查 |
+
+```python
+# check_health() - ServiceProxy级别
+svc = store.for_store().find_service("weather")
+health = svc.check_health() # 只检查weather服务
+
+# check_services() - Context级别
+health_all = store.for_store().check_services() # 检查所有服务
+```
+
+## 相关方法
+
+- [health_details()](health-details.md) - 获取详细健康信息
+- [check_services()](check-services.md) - 检查所有服务健康状态
+- [service_status()](../details/service-status.md) - 获取服务状态
+- [wait_service()](../waiting/wait-service.md) - 等待服务就绪
+
+## 注意事项
+
+- 调用前提: 必须先通过 `find_service()` 获取 ServiceProxy 对象
+- 性能影响: 健康检查会执行实际的 ping 操作
+- 缓存机制: 结果有短暂缓存,避免频繁检查
+- 网络依赖: 远程服务依赖网络连接
+
diff --git a/docs/docs/services/delete-service.md b/docs/docs/services/delete-service.md
new file mode 100644
index 00000000..7b47e2af
--- /dev/null
+++ b/docs/docs/services/delete-service.md
@@ -0,0 +1,247 @@
+## delete_service - 删除服务
+
+删除指定服务。
+
+### SDK
+
+同步:
+ - `store.for_store().delete_service(name) -> bool`
+ - `store.for_agent(id).delete_service(name) -> bool`
+
+异步:
+ - `await store.for_store().delete_service_async(name) -> bool`
+ - `await store.for_agent(id).delete_service_async(name) -> bool`
+
+## 参数
+
+| 参数名 | 类型 | 必需 | 默认值 | 描述 |
+|--------|------|------|--------|------|
+| `name` | `str` | ✅ | - | 服务名称 |
+
+## 返回值
+
+- **成功**: 返回 `True`
+- **失败**: 返回 `False`(服务不存在或删除失败)
+
+## 使用示例
+
+### Store级别删除服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 删除服务
+success = store.for_store().delete_service("weather")
+if success:
+ print("Weather服务已删除")
+else:
+ print("Weather服务删除失败或不存在")
+```
+
+### Agent级别删除服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# Agent模式删除服务
+success = store.for_agent("agent1").delete_service("weather-local")
+if success:
+ print("Agent Weather服务已删除")
+else:
+ print("Agent Weather服务删除失败")
+```
+
+### 安全删除(先检查后删除)
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 先检查服务是否存在
+services = store.for_store().list_services()
+service_names = [s.name for s in services]
+
+if "weather" in service_names:
+ success = store.for_store().delete_service("weather")
+ if success:
+ print("Weather服务已安全删除")
+ else:
+ print("Weather服务删除失败")
+else:
+ print("Weather服务不存在,无需删除")
+```
+
+### 异步版本
+
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_delete_service():
+ # 初始化
+ store = MCPStore.setup_store()
+
+ # 异步删除服务
+ success = await store.for_store().delete_service_async("weather")
+
+ if success:
+ print("异步删除成功")
+ # 验证删除结果
+ services = await store.for_store().list_services_async()
+ remaining_names = [s.name for s in services]
+ print(f"剩余服务: {remaining_names}")
+ else:
+ print("异步删除失败")
+
+ return success
+
+# 运行异步删除
+result = asyncio.run(async_delete_service())
+```
+
+### 批量删除服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 批量删除多个服务
+services_to_delete = ["weather", "database", "filesystem"]
+
+deleted_count = 0
+for service_name in services_to_delete:
+ success = store.for_store().delete_service(service_name)
+ if success:
+ print(f"{service_name} 删除成功")
+ deleted_count += 1
+ else:
+ print(f"{service_name} 删除失败")
+
+print(f"总计删除 {deleted_count}/{len(services_to_delete)} 个服务")
+```
+
+### 条件删除
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 获取所有服务
+services = store.for_store().list_services()
+
+# 删除不健康的服务
+health_status = store.for_store().check_services()
+for service in services:
+ if service.name in health_status:
+ status = health_status[service.name]['status']
+ if status == 'unhealthy':
+ success = store.for_store().delete_service(service.name)
+ print(f"删除不健康服务 {service.name}: {'成功' if success else '失败'}")
+```
+
+### 删除前备份配置
+
+```python
+from mcpstore import MCPStore
+import json
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 删除前备份服务配置
+service_name = "weather"
+try:
+ # 获取服务配置
+ service_info = store.for_store().get_service_info(service_name)
+
+ # 备份配置到文件
+ backup_file = f"{service_name}_backup.json"
+ with open(backup_file, 'w') as f:
+ json.dump(service_info, f, indent=2)
+
+ # 删除服务
+ success = store.for_store().delete_service(service_name)
+ if success:
+ print(f"服务 {service_name} 已删除,配置已备份到 {backup_file}")
+ else:
+ print(f"服务 {service_name} 删除失败")
+
+except Exception as e:
+ print(f"备份或删除过程中出错: {e}")
+```
+
+### 删除并清理相关资源
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+def delete_service_completely(service_name):
+ """完全删除服务及相关资源"""
+
+ # 1. 获取服务信息
+ try:
+ service_info = store.for_store().get_service_info(service_name)
+ print(f"准备删除服务: {service_name}")
+ except:
+ print(f"服务 {service_name} 不存在")
+ return False
+
+ # 2. 删除服务
+ success = store.for_store().delete_service(service_name)
+ if not success:
+ print(f"服务 {service_name} 删除失败")
+ return False
+
+ # 3. 验证删除结果
+ services = store.for_store().list_services()
+ remaining_names = [s.name for s in services]
+
+ if service_name not in remaining_names:
+ print(f"服务 {service_name} 已完全删除")
+ return True
+ else:
+ print(f"服务 {service_name} 删除验证失败")
+ return False
+
+# 使用完全删除功能
+delete_service_completely("weather")
+```
+
+## 删除影响
+
+删除服务会产生以下影响:
+
+- 服务连接: 立即断开与服务的连接
+- 工具可用性: 该服务的所有工具将不可用
+- 配置清理: 从配置文件中移除服务配置
+- 缓存清理: 清除相关的缓存数据
+- 客户端清理: 清理相关的客户端连接
+
+## 相关方法
+
+- [add_service()](../registration/add-service.md) - 重新添加服务
+- [list_services()](../listing/list-services.md) - 查看剩余服务
+- [get_service_info()](../listing/get-service-info.md) - 删除前获取服务信息
+
+## 注意事项
+
+- 不可逆操作: 删除操作不可逆,建议删除前备份配置
+- 工具影响: 删除服务会使其所有工具不可用
+- Agent 隔离: Agent 模式下只能删除该 Agent 的服务
+- 连接清理: 删除时会自动清理相关连接和缓存
+- 配置持久化: 删除会同时更新配置文件
diff --git a/docs/docs/services/list-services.md b/docs/docs/services/list-services.md
new file mode 100644
index 00000000..5b3de78a
--- /dev/null
+++ b/docs/docs/services/list-services.md
@@ -0,0 +1,361 @@
+## list_services - 服务列表查询
+
+`list_services()` 提供服务列表查询,支持 Store/Agent 双模式,返回 `ServiceInfo` 列表,包含服务状态、生命周期与配置信息。
+
+### SDK
+
+同步:
+ - `store.for_store().list_services() -> List[ServiceInfo]`
+ - `store.for_agent(id).list_services() -> List[ServiceInfo]`
+
+异步:
+ - `await store.for_store().list_services_async() -> List[ServiceInfo]`
+ - `await store.for_agent(id).list_services_async() -> List[ServiceInfo]`
+
+### 返回值
+
+返回 `List[ServiceInfo]`。
+
+### ServiceInfo 模型
+
+```python
+class ServiceInfo:
+ # 基础信息
+ name: str # 服务名称
+ url: Optional[str] # 服务URL(远程服务)
+ command: Optional[str] # 启动命令(本地服务)
+ args: Optional[List[str]] # 命令参数
+
+ # 传输和连接
+ transport_type: TransportType # 传输类型
+ client_id: Optional[str] # 客户端ID
+ keep_alive: bool # 是否保持连接
+
+ # 状态信息
+ status: ServiceConnectionState # 服务连接状态
+ tool_count: int # 工具数量
+
+ # 环境配置
+ working_dir: Optional[str] # 工作目录
+ env: Optional[Dict[str, str]] # 环境变量
+ package_name: Optional[str] # 包名
+
+ # 生命周期元数据
+ state_metadata: Optional[ServiceStateMetadata] # 状态元数据
+
+ # 配置信息
+ config: Optional[Dict[str, Any]] # 原始配置
+```
+
+### ServiceStateMetadata 模型
+
+```python
+class ServiceStateMetadata:
+ consecutive_failures: int = 0 # 连续失败次数
+ consecutive_successes: int = 0 # 连续成功次数
+ last_ping_time: Optional[datetime] # 最后Ping时间
+ last_success_time: Optional[datetime] # 最后成功时间
+ last_failure_time: Optional[datetime] # 最后失败时间
+ response_time: Optional[float] # 响应时间
+ error_message: Optional[str] # 错误消息
+ reconnect_attempts: int = 0 # 重连尝试次数
+ next_retry_time: Optional[datetime] # 下次重试时间
+ state_entered_time: Optional[datetime] # 状态进入时间
+ disconnect_reason: Optional[str] # 断开原因
+ service_config: Dict[str, Any] # 服务配置
+ service_name: Optional[str] # 服务名称
+ agent_id: Optional[str] # Agent ID
+ last_health_check: Optional[datetime] # 最后健康检查
+ last_response_time: Optional[float] # 最后响应时间
+```
+
+### 视角
+
+Store 视角返回全局注册的所有服务;Agent 视角仅返回该 Agent 的服务,并将带后缀的全局名映射为本地名。
+
+```python
+# Store 视角
+store_services = store.for_store().list_services()
+
+# Agent 视角
+agent_services = store.for_agent("agent1").list_services()
+```
+
+### 示例
+
+基础服务列表查询:
+```python
+from mcpstore import MCPStore
+
+def basic_service_listing():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ print(f"总服务数: {len(services)}")
+ for service in services:
+ print(f"- {service.name}")
+ print(f" 状态: {service.status}")
+ print(f" 类型: {'远程' if service.url else '本地'}")
+ print(f" 工具: {service.tool_count}")
+
+basic_service_listing()
+```
+
+Agent 级别服务列表:
+```python
+from mcpstore import MCPStore
+
+def agent_service_listing():
+ store = MCPStore.setup_store()
+ agent_id = "research_agent"
+ agent_services = store.for_agent(agent_id).list_services()
+ print(f"Agent '{agent_id}' 服务数: {len(agent_services)}")
+ for service in agent_services:
+ print(f"- {service.name}")
+ print(f" 状态: {service.status}")
+ print(f" 客户端ID: {service.client_id}")
+ if service.state_metadata:
+ m = service.state_metadata
+ print(f" 连续成功: {m.consecutive_successes}")
+ print(f" 连续失败: {m.consecutive_failures}")
+
+agent_service_listing()
+```
+
+详细服务信息展示:
+```python
+from mcpstore import MCPStore
+
+def detailed_service_info():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ print("详细服务信息")
+ print("=" * 40)
+ for service in services:
+ print(f"服务: {service.name}")
+ print(f" 状态: {service.status}")
+ print(f" 传输: {service.transport_type}")
+ print(f" 工具: {service.tool_count}")
+ if service.url:
+ print(f" URL: {service.url}")
+ elif service.command:
+ print(f" 命令: {service.command}")
+ if service.args:
+ print(f" 参数: {' '.join(service.args)}")
+ if service.working_dir:
+ print(f" 工作目录: {service.working_dir}")
+ if service.env:
+ print(f" 环境变量: {len(service.env)}")
+ if service.state_metadata:
+ m = service.state_metadata
+ print(f" 响应时间: {m.response_time}ms")
+ print(f" 重连次数: {m.reconnect_attempts}")
+ if m.error_message:
+ print(f" 错误: {m.error_message}")
+ print(f" 客户端ID: {service.client_id}")
+ print("-" * 30)
+
+detailed_service_info()
+```
+
+服务状态统计:
+```python
+from mcpstore import MCPStore
+
+def service_statistics():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ status_counts = {}
+ transport_counts = {}
+ total_tools = 0
+ for service in services:
+ status_counts[service.status] = status_counts.get(service.status, 0) + 1
+ transport_counts[service.transport_type] = transport_counts.get(service.transport_type, 0) + 1
+ total_tools += service.tool_count
+ print("服务统计")
+ print(f"总服务数: {len(services)}")
+ print(f"总工具数: {total_tools}")
+ print("状态分布:")
+ for k, v in status_counts.items():
+ print(f" {k}: {v}")
+ print("传输类型分布:")
+ for k, v in transport_counts.items():
+ print(f" {k}: {v}")
+
+service_statistics()
+```
+
+异步服务列表查询:
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_service_listing():
+ store = MCPStore.setup_store()
+ services = await store.for_store().list_services_async()
+ print(f"异步服务数: {len(services)}")
+ agent_ids = ["agent1", "agent2", "agent3"]
+ tasks = [store.for_agent(a).list_services_async() for a in agent_ids]
+ agent_lists = await asyncio.gather(*tasks)
+ for a, lst in zip(agent_ids, agent_lists):
+ print(f"Agent {a}: {len(lst)}")
+
+# asyncio.run(async_service_listing())
+```
+
+### 高级用法
+
+按状态筛选:
+```python
+from mcpstore import MCPStore
+
+def filter_services_by_status():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ healthy = [s for s in services if s.status == "healthy"]
+ problems = [s for s in services if s.status in ["warning", "reconnecting", "unreachable"]]
+ print(f"健康: {len(healthy)}")
+ print(f"异常: {len(problems)}")
+ for s in problems:
+ print(f"- {s.name}: {s.status}")
+ if s.state_metadata and s.state_metadata.error_message:
+ print(f" 错误: {s.state_metadata.error_message}")
+
+filter_services_by_status()
+```
+
+按传输类型分组:
+```python
+from mcpstore import MCPStore
+
+def group_services_by_transport():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ groups = {}
+ for s in services:
+ groups.setdefault(s.transport_type, []).append(s)
+ for transport, items in groups.items():
+ print(f"{transport} ({len(items)})")
+ for s in items:
+ print(f"- {s.name}: {s.status}")
+
+group_services_by_transport()
+```
+
+服务性能分析:
+```python
+from mcpstore import MCPStore
+
+def analyze_service_performance():
+ store = MCPStore.setup_store()
+ services = store.for_store().list_services()
+ data = []
+ for s in services:
+ if s.state_metadata:
+ m = s.state_metadata
+ total = m.consecutive_successes + m.consecutive_failures + 1
+ data.append({
+ "name": s.name,
+ "response_time": m.response_time or 0,
+ "success_rate": m.consecutive_successes / total * 100,
+ "reconnect_attempts": m.reconnect_attempts,
+ })
+ data.sort(key=lambda x: x["response_time"])
+ print("名称 响应时间 成功率 重连次数")
+ print("-" * 50)
+ for d in data:
+ print(f"{d['name']:<16} {d['response_time']:<10.2f} {d['success_rate']:<10.1f} {d['reconnect_attempts']:<10}")
+
+analyze_service_performance()
+```
+
+Store 与 Agent 对比:
+```python
+from mcpstore import MCPStore
+
+def compare_store_vs_agent_services():
+ store = MCPStore.setup_store()
+ store_services = store.for_store().list_services()
+ agent_id = "test_agent"
+ agent_services = store.for_agent(agent_id).list_services()
+ print("Store 级别服务")
+ for s in store_services:
+ print(f"- {s.name} ({s.status})")
+ print(f"Agent {agent_id} 服务")
+ for s in agent_services:
+ print(f"- {s.name} ({s.status})")
+ store_names = {s.name for s in store_services}
+ agent_names = {s.name for s in agent_services}
+ print(f"Store 独有: {store_names - agent_names}")
+ print(f"Agent 独有: {agent_names - store_names}")
+ print(f"共同: {store_names & agent_names}")
+
+compare_store_vs_agent_services()
+```
+
+## API 响应格式
+
+### Store API 响应
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "name": "weather-api",
+ "status": "healthy",
+ "transport": "streamable-http",
+ "config": {
+ "url": "https://weather.example.com/mcp",
+ "headers": {"Authorization": "Bearer token"}
+ },
+ "client_id": "global_agent_store:weather-api"
+ }
+ ],
+ "message": "Retrieved 1 services for store"
+}
+```
+
+### Agent API 响应
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "name": "weather-api",
+ "status": "healthy",
+ "transport": "streamable-http",
+ "config": {
+ "url": "https://weather.example.com/mcp"
+ },
+ "client_id": "agent1:weather-api"
+ }
+ ],
+ "message": "Retrieved 1 services for agent 'agent1'"
+}
+```
+
+### 性能
+
+- 平均耗时:约 0.002 秒
+- 缓存机制:内存缓存,实时更新
+- 并发支持:支持异步并发查询
+- 数据一致性:实时反映服务状态
+
+### 相关文档
+
+- [get_service_info()](get-service-info.md)
+- [服务注册](../registration/add-service.md)
+- [服务生命周期](../lifecycle/service-lifecycle.md)
+- [工具列表查询](../../tools/listing/list-tools.md)
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|---------------------|-------------------------------------------------------|
+| 获取服务信息 | `store.for_store().get_service_info(name)` |
+| 获取工具列表 | `store.for_store().list_tools(name)` |
+| 等待服务就绪 | `store.for_store().wait_service(name, status=...)` |
+| 注册服务 | `store.for_store().add_service(config=..., ...)` |
+| 删除服务 | `store.for_store().delete_service(name)` |
+| 更新服务 | `store.for_store().update_service(name, config=...)` |
diff --git a/docs/docs/services/overview.md b/docs/docs/services/overview.md
new file mode 100644
index 00000000..8baf7f7c
--- /dev/null
+++ b/docs/docs/services/overview.md
@@ -0,0 +1,257 @@
+# 服务管理概览
+
+MCPStore 提供了完整的服务生命周期管理功能,按照功能分类为8个核心模块,涵盖从添加到删除的全流程操作。
+
+## 📋 **服务管理8大模块**
+
+### 1. 📝 **添加服务**
+添加 MCP 服务,支持多种配置格式。
+
+**核心方法**:
+- **[add_service()](registration/add-service.md)** - 添加服务(支持单个/批量)
+
+**相关文档**:
+- [配置格式速查表](registration/config-formats.md) - 支持的配置格式
+- [完整示例集合](registration/examples.md) - 各种使用示例
+
+---
+
+### 2. 🔍 **查找服务**
+查找已注册的服务,获取服务代理对象或列表。
+
+**核心方法**:
+- **[find_service()](listing/find-service.md)** - 查找服务并返回 ServiceProxy
+- **[list_services()](listing/list-services.md)** - 列出所有已注册服务
+
+**相关文档**:
+- [服务代理(ServiceProxy)](listing/service-proxy.md) - ServiceProxy 概念说明
+
+---
+
+### 3. 📊 **服务详情**
+获取服务的详细信息和当前状态。
+
+**核心方法**:
+- **[service_info()](details/service-info.md)** - 获取服务详细信息
+- **[service_status()](details/service-status.md)** - 获取服务当前状态
+
+> 💡 **提示**: 这些方法需要先通过 `find_service()` 获取 ServiceProxy 对象后调用
+
+---
+
+### 4. ⏳ **等待服务**
+等待服务达到指定状态,确保服务就绪后再进行操作。
+
+**核心方法**:
+- **[wait_service()](waiting/wait-service.md)** - 等待服务就绪
+
+**使用场景**:
+- 添加服务后等待初始化完成
+- 重启服务后等待恢复
+- 批量服务初始化同步
+
+---
+
+### 5. 🏥 **健康检查**
+检查服务的健康状态和性能指标。
+
+**核心方法**:
+- **[check_services()](health/check-services.md)** - 检查所有服务健康状态(Context级别)
+- **[check_health()](health/check-health.md)** - 检查单个服务健康摘要(ServiceProxy级别)
+- **[health_details()](health/health-details.md)** - 获取单个服务详细健康信息(ServiceProxy级别)
+
+**对比**:
+| 方法 | 调用层级 | 检查范围 | 信息量 |
+|------|----------|----------|--------|
+| check_services() | Context | 所有服务 | 基础 |
+| check_health() | ServiceProxy | 单个服务 | 摘要 |
+| health_details() | ServiceProxy | 单个服务 | 详细 |
+
+---
+
+### 6. ⚙️ **更新服务**
+更新服务配置,支持全量和增量更新。
+
+**核心方法**:
+- **[update_config()](management/update-service.md)** - 全量更新服务配置
+- **[patch_config()](management/patch-service.md)** - 增量更新服务配置(推荐)
+
+**区别**:
+- `update_config()`: 完全替换配置,未提供的字段会被清空
+- `patch_config()`: 只更新指定字段,其他字段保持不变
+
+---
+
+### 7. 🔄 **重启服务**
+重启服务或刷新服务内容。
+
+**核心方法**:
+- **[restart_service()](management/restart-service.md)** - 重启服务(完全重启)
+- **[refresh_content()](management/refresh-content.md)** - 刷新服务内容(仅刷新工具列表等)
+
+**区别**:
+- `restart_service()`: 断开重连,重新初始化服务
+- `refresh_content()`: 保持连接,只刷新内容
+
+---
+
+### 8. 🗑️ **删除服务**
+删除或移除服务,支持保留配置或完全清理。
+
+**核心方法**:
+- **[remove_service()](management/remove-service.md)** - 移除服务运行态(保留配置)
+- **[delete_service()](management/delete-service.md)** - 完全删除服务(配置+缓存)
+
+**区别**:
+- `remove_service()`: 只清理运行态,配置保留,可快速恢复
+- `delete_service()`: 完全删除,需要重新配置
+
+---
+
+## 🎯 **快速开始**
+
+### 完整的服务管理流程
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 1️⃣ 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 2️⃣ 等待服务就绪
+store.for_store().wait_service("weather", timeout=30.0)
+
+# 3️⃣ 查找服务
+svc = store.for_store().find_service("weather")
+
+# 4️⃣ 获取服务详情
+info = svc.service_info()
+print(f"服务名称: {info.name}")
+print(f"工具数量: {info.tool_count}")
+
+# 5️⃣ 检查健康状态
+health = svc.check_health()
+print(f"健康状态: {health['healthy']}")
+
+# 6️⃣ 更新配置(如需要)
+svc.patch_config({"keep_alive": True})
+
+# 7️⃣ 使用服务
+tools = svc.list_tools()
+print(f"可用工具: {len(tools)} 个")
+
+# 8️⃣ 清理(可选)
+svc.remove_service() # 或 svc.delete_service()
+```
+
+### Store vs Agent 模式
+
+MCPStore 支持两种服务管理模式:
+
+```python
+# Store 级别(全局共享)
+store.for_store().add_service({"mcpServers": {...}})
+store.for_store().wait_service("weather")
+svc = store.for_store().find_service("weather")
+
+# Agent 级别(独立隔离)
+store.for_agent("agent1").add_service({"mcpServers": {...}})
+store.for_agent("agent1").wait_service("weather")
+svc = store.for_agent("agent1").find_service("weather")
+```
+
+| 特性 | Store 级别 | Agent 级别 |
+|------|------------|------------|
+| **访问范围** | 全局共享 | 独立隔离 |
+| **配置文件** | mcp.json | agent配置 |
+| **适用场景** | 基础服务 | 专用服务 |
+
+---
+
+## 📋 **配置管理**
+
+除了8大服务管理模块外,还提供配置管理功能:
+
+**核心方法**:
+- **[reset_config()](config/reset-config.md)** - 重置配置
+- **[show_config()](config/show-config.md)** - 显示配置信息
+
+---
+
+## 🎭 **调用层级说明**
+
+MCPStore 的服务方法分为两个调用层级:
+
+### Context 层级
+通过 `store.for_store()` 或 `store.for_agent()` 调用:
+
+```python
+# Context 层级方法
+store.for_store().add_service(...) # 添加服务
+store.for_store().list_services() # 列出服务
+store.for_store().find_service("name") # 查找服务
+store.for_store().wait_service("name") # 等待服务
+store.for_store().check_services() # 检查所有服务
+```
+
+### ServiceProxy 层级
+通过 `find_service()` 返回的代理对象调用:
+
+```python
+# ServiceProxy 层级方法
+svc = store.for_store().find_service("name")
+
+svc.service_info() # 服务详情
+svc.service_status() # 服务状态
+svc.check_health() # 健康检查
+svc.health_details() # 详细健康信息
+svc.update_config({}) # 更新配置
+svc.patch_config({}) # 增量更新
+svc.restart_service() # 重启服务
+svc.refresh_content() # 刷新内容
+svc.remove_service() # 移除服务
+svc.delete_service() # 删除服务
+```
+
+---
+
+## 🔗 **相关文档**
+
+- [服务架构设计](architecture.md) - 了解服务管理的架构设计
+- [配置格式说明](registration/config-formats.md) - 学习各种服务配置格式
+- [ServiceProxy 概念](listing/service-proxy.md) - 理解服务代理机制
+- [最佳实践](../advanced/best-practices.md) - 服务管理最佳实践
+
+---
+
+## 📊 **方法速查表**
+
+| 功能 | 方法 | 调用层级 | 文档 |
+|------|------|----------|------|
+| **添加** | add_service() | Context | [查看](registration/add-service.md) |
+| **查找** | find_service() | Context | [查看](listing/find-service.md) |
+| **列表** | list_services() | Context | [查看](listing/list-services.md) |
+| **详情** | service_info() | ServiceProxy | [查看](details/service-info.md) |
+| **状态** | service_status() | ServiceProxy | [查看](details/service-status.md) |
+| **等待** | wait_service() | Context | [查看](waiting/wait-service.md) |
+| **健康** | check_services() | Context | [查看](health/check-services.md) |
+| **健康** | check_health() | ServiceProxy | [查看](health/check-health.md) |
+| **健康详情** | health_details() | ServiceProxy | [查看](health/health-details.md) |
+| **更新** | update_config() | ServiceProxy | [查看](management/update-service.md) |
+| **增量更新** | patch_config() | ServiceProxy | [查看](management/patch-service.md) |
+| **重启** | restart_service() | ServiceProxy | [查看](management/restart-service.md) |
+| **刷新** | refresh_content() | ServiceProxy | [查看](management/refresh-content.md) |
+| **移除** | remove_service() | ServiceProxy | [查看](management/remove-service.md) |
+| **删除** | delete_service() | ServiceProxy | [查看](management/delete-service.md) |
+
+---
+
+**更新时间**: 2025-01-09
+**版本**: 2.0.0
diff --git a/docs/docs/services/restart-service.md b/docs/docs/services/restart-service.md
new file mode 100644
index 00000000..1a1fe9c7
--- /dev/null
+++ b/docs/docs/services/restart-service.md
@@ -0,0 +1,286 @@
+## restart_service - 服务重启
+
+重启指定服务。
+
+### SDK
+
+同步:
+ - `store.for_store().restart_service(name) -> bool`
+ - `store.for_agent(id).restart_service(name) -> bool`
+
+异步:
+ - `await store.for_store().restart_service_async(name) -> bool`
+ - `await store.for_agent(id).restart_service_async(name) -> bool`
+
+## 参数
+
+| 参数名 | 类型 | 必需 | 默认值 | 描述 |
+|--------|------|------|--------|------|
+| `name` | `str` | ✅ | - | 服务名称 |
+
+## 返回值
+
+- **成功**: 返回 `True`
+- **失败**: 返回 `False`
+
+## 使用示例
+
+### Store级别重启服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 重启服务
+success = store.for_store().restart_service("weather")
+if success:
+ print("Weather服务重启成功")
+else:
+ print("Weather服务重启失败")
+```
+
+### Agent级别重启服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# Agent模式重启服务
+success = store.for_agent("agent1").restart_service("weather-local")
+if success:
+ print("Agent Weather服务重启成功")
+```
+
+### 重启前检查状态
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 检查服务状态
+status = store.for_store().get_service_status("weather")
+print(f"重启前状态: {status['status']}")
+
+if status['status'] != 'healthy':
+ # 重启不健康的服务
+ success = store.for_store().restart_service("weather")
+ if success:
+ print("服务重启成功")
+
+ # 等待服务恢复
+ ready = store.for_store().wait_service("weather", "healthy", timeout=30.0)
+ if ready:
+ print("服务已恢复健康状态")
+ else:
+ print("服务重启后仍未恢复")
+ else:
+ print("服务重启失败")
+else:
+ print("服务状态正常,无需重启")
+```
+
+### 异步版本
+
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_restart_service():
+ # 初始化
+ store = MCPStore.setup_store()
+
+ # 异步重启服务
+ success = await store.for_store().restart_service_async("weather")
+
+ if success:
+ print("异步重启成功")
+
+ # 异步等待服务恢复
+ ready = await store.for_store().wait_service_async("weather", "healthy", timeout=30.0)
+ if ready:
+ print("服务已异步恢复")
+ else:
+ print("异步重启失败")
+
+ return success
+
+# 运行异步重启
+result = asyncio.run(async_restart_service())
+```
+
+### 批量重启服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 批量重启多个服务
+services_to_restart = ["weather", "database", "filesystem"]
+
+restart_results = {}
+for service_name in services_to_restart:
+ success = store.for_store().restart_service(service_name)
+ restart_results[service_name] = success
+ print(f"重启 {service_name}: {'成功' if success else '失败'}")
+
+# 统计结果
+successful_restarts = sum(1 for success in restart_results.values() if success)
+print(f"总计重启成功: {successful_restarts}/{len(services_to_restart)} 个服务")
+```
+
+### 智能重启(仅重启不健康的服务)
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+def smart_restart():
+ """智能重启:只重启不健康的服务"""
+
+ # 检查所有服务健康状态
+ health_status = store.for_store().check_services()
+
+ unhealthy_services = []
+ for service_name, status in health_status.items():
+ if status['status'] != 'healthy':
+ unhealthy_services.append(service_name)
+
+ if not unhealthy_services:
+ print("所有服务状态正常,无需重启")
+ return True
+
+ print(f"发现 {len(unhealthy_services)} 个不健康服务,开始重启...")
+
+ restart_success = 0
+ for service_name in unhealthy_services:
+ print(f"重启服务: {service_name}")
+ success = store.for_store().restart_service(service_name)
+
+ if success:
+ restart_success += 1
+ print(f" {service_name} 重启成功")
+
+ # 等待服务恢复
+ ready = store.for_store().wait_service(service_name, "healthy", timeout=20.0)
+ if ready:
+ print(f" {service_name} 已恢复健康")
+ else:
+ print(f" {service_name} 重启后仍未恢复")
+ else:
+ print(f" {service_name} 重启失败")
+
+ print(f"智能重启完成: {restart_success}/{len(unhealthy_services)} 个服务重启成功")
+ return restart_success == len(unhealthy_services)
+
+# 执行智能重启
+smart_restart()
+```
+
+### 重启后验证
+
+```python
+from mcpstore import MCPStore
+import time
+
+# 初始化
+store = MCPStore.setup_store()
+
+def restart_with_verification(service_name):
+ """重启服务并验证结果"""
+
+ print(f"开始重启服务: {service_name}")
+
+ # 1. 记录重启前状态
+ try:
+ before_status = store.for_store().get_service_status(service_name)
+ print(f"重启前状态: {before_status['status']}")
+ except:
+ print("无法获取重启前状态")
+ before_status = None
+
+ # 2. 执行重启
+ restart_time = time.time()
+ success = store.for_store().restart_service(service_name)
+ restart_duration = time.time() - restart_time
+
+ if not success:
+ print(f"服务重启失败 (耗时: {restart_duration:.2f}秒)")
+ return False
+
+ print(f"服务重启成功 (耗时: {restart_duration:.2f}秒)")
+
+ # 3. 等待服务恢复
+ print("等待服务恢复...")
+ ready = store.for_store().wait_service(service_name, "healthy", timeout=30.0)
+
+ if ready:
+ # 4. 验证重启后状态
+ after_status = store.for_store().get_service_status(service_name)
+ print(f"重启后状态: {after_status['status']}")
+
+ # 5. 验证工具可用性
+ try:
+ tools = store.for_store().list_tools()
+ service_tools = [t for t in tools if service_name in t.name]
+ print(f"服务工具数量: {len(service_tools)}")
+
+ if service_tools:
+ print("✅ 服务重启验证成功")
+ return True
+ else:
+ print("⚠️ 服务重启后工具不可用")
+ return False
+
+ except Exception as e:
+ print(f"工具验证失败: {e}")
+ return False
+ else:
+ print("服务重启后未能恢复健康状态")
+ return False
+
+# 使用验证重启
+restart_with_verification("weather")
+```
+
+## 重启流程
+
+重启服务包含以下步骤:
+
+- 断开连接: 断开与服务的现有连接
+- 清理资源: 清理相关的缓存和临时数据
+- 重新连接: 使用原有配置重新建立连接
+- 健康检查: 验证服务是否正常启动
+- 工具刷新: 重新获取服务提供的工具列表
+
+## 常见重启场景
+
+- 配置更新后: 使新配置生效
+- 服务不健康: 尝试恢复服务状态
+- 连接异常: 重新建立连接
+- 工具更新: 刷新工具列表
+- 故障恢复: 从错误状态中恢复
+
+## 相关方法
+
+- [get_service_status()](../health/get-service-status.md) - 检查重启前后状态
+- [wait_service()](../health/wait-service.md) - 等待重启完成
+- [update_service()](update-service.md) - 更新配置后重启
+- [check_services()](../health/check-services.md) - 批量检查服务状态
+
+## 注意事项
+
+- 服务中断: 重启过程中服务暂时不可用
+- 工具影响: 重启会导致该服务的工具暂时不可用
+- Agent 映射: Agent 模式下自动处理服务名映射
+- 超时设置: 重启操作有内置超时机制
+- 状态验证: 建议重启后验证服务状态和工具可用性
diff --git a/docs/docs/services/service-proxy.md b/docs/docs/services/service-proxy.md
new file mode 100644
index 00000000..490bcdd6
--- /dev/null
+++ b/docs/docs/services/service-proxy.md
@@ -0,0 +1,72 @@
+# 服务代理(ServiceProxy)
+
+> 通过 `find_service(name)` 获得的对象,封装了“该服务”相关的全部操作,方法命名采用两词法。
+
+- 实现位置:src/mcpstore/core/context/service_proxy.py
+- 设计目标:
+ - 缩小作用域:所有操作都绑定在一个具体服务上
+ - 命名统一:方法采用“两词法”,与 SDK 其他接口风格一致
+ - 兼容 agent/store 两种上下文,透明处理服务名映射(Agent 本地名 ↔ 全局名)
+
+## 核心方法与属性
+
+- 信息与状态
+ - service_info() → 返回服务详情(ServiceInfo + 工具清单)
+ - service_status() → 返回缓存状态快照(status、healthy、last_check、response_time 等)
+ - check_health() → 返回健康摘要(service_name、status、healthy、response_time、error_message)
+ - health_details() → 返回健康详情(effective_name、lifecycle_state、response_time、timestamp、error_message、details)
+ - is_healthy() → bool
+ - is_connected → bool(属性,带回退判断)
+
+- 工具
+ - list_tools() → List[ToolInfo](优先 Registry 按服务获取,失败回退全量过滤)
+ - tools_stats() → Dict(仅当前服务的工具统计 + 清单)
+
+- 配置与运行态管理
+ - update_config(config) → bool(单一数据源 mcp.json 写入 + 同步 + 缓存更新)
+ - patch_config(updates) → bool(增量更新)
+ - restart_service() → bool
+ - refresh_content() → bool(同步封装 await)
+ - remove_service() → bool(运行态移除/断连)
+ - delete_service() → bool(配置+缓存删除)
+
+- 便捷属性
+ - name:服务名
+ - context_type:上下文类型(store/agent)
+ - tools_count:工具数量
+
+## 返回结构与字段说明
+
+- ServiceInfo(主要字段)
+ - name、url、transport_type、status(7 状态)、tool_count、keep_alive、working_dir、env、command、args、package_name
+ - state_metadata(consecutive_failures、last_ping_time、error_message、service_config 等)
+ - last_state_change、client_id、config
+
+- 工具详情(工具列表元素字段)
+ - name(显示名)、display_name(友好展示名)、original_name(FastMCP 原始名)、description
+ - inputSchema(JSON Schema)、service_name、client_id
+
+## Agent 上下文的透明映射
+
+- find_service 返回的 ServiceProxy 在 Agent 上下文会自动处理“本地名 ↔ 全局名”映射:
+ - health_details 会对 effective_name 使用全局名
+ - list_tools/tools_stats 会在内部以全局名查工具后转换为本地名展示
+
+## 示例
+
+```python
+from mcpstore import MCPStore
+store = MCPStore.setup_store()
+
+# Store
+svc = store.for_store().find_service("mcpstore-demo-weather")
+print(svc.service_info())
+print(svc.tools_stats())
+print(svc.check_health())
+
+# Agent
+svc2 = store.for_agent("agent_demo").find_service("mcpstore-demo-weather")
+print(svc2.service_status())
+print(svc2.health_details())
+```
+
diff --git a/docs/docs/services/show-config.md b/docs/docs/services/show-config.md
new file mode 100644
index 00000000..ca6c9aa4
--- /dev/null
+++ b/docs/docs/services/show-config.md
@@ -0,0 +1,313 @@
+# show_config()
+
+显示配置信息。
+
+## 方法特性
+
+- ✅ **异步版本**: `show_config_async()`
+- ✅ **Store级别**: `store.for_store().show_config()`
+- ✅ **Agent级别**: `store.for_agent("agent1").show_config()`
+- 📁 **文件位置**: `service_management.py`
+- 🏷️ **所属类**: `ServiceManagementMixin`
+
+## 参数
+
+| 参数名 | 类型 | 必需 | 默认值 | 描述 |
+|--------|------|------|--------|------|
+| `scope` | `str` | ❌ | `"all"` | 显示范围 |
+
+## 显示范围选项
+
+| 范围值 | 描述 | 返回内容 |
+|--------|------|----------|
+| `"all"` | 显示所有配置 | 服务配置、Agent配置、客户端配置 |
+| `"mcp"` | 显示MCP配置 | mcp.json中的服务配置 |
+| `"agent"` | 显示Agent配置 | Agent客户端映射 |
+| `"client"` | 显示客户端配置 | 客户端服务映射 |
+
+## 返回值
+
+返回包含配置信息的字典,格式根据范围而定。
+
+## 使用示例
+
+### Store级别显示所有配置
+
+```python
+from mcpstore import MCPStore
+import json
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 显示所有配置
+config = store.for_store().show_config("all")
+print("完整配置:")
+print(json.dumps(config, indent=2, ensure_ascii=False))
+```
+
+### Agent级别显示配置
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# Agent模式显示配置
+agent_config = store.for_agent("agent1").show_config()
+print(f"Agent1配置: {agent_config}")
+```
+
+### 显示特定范围的配置
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 显示MCP服务配置
+mcp_config = store.for_store().show_config("mcp")
+print("MCP服务配置:")
+for service_name, service_config in mcp_config.get("mcpServers", {}).items():
+ print(f" {service_name}: {service_config}")
+
+# 显示Agent配置
+agent_config = store.for_store().show_config("agent")
+print(f"Agent配置: {agent_config}")
+
+# 显示客户端配置
+client_config = store.for_store().show_config("client")
+print(f"客户端配置: {client_config}")
+```
+
+### 异步版本
+
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_show_config():
+ # 初始化
+ store = MCPStore.setup_store()
+
+ # 异步显示配置
+ config = await store.for_store().show_config_async("all")
+
+ print("异步获取的配置:")
+ print(f"服务数量: {len(config.get('mcpServers', {}))}")
+ print(f"Agent数量: {len(config.get('agents', {}))}")
+ print(f"客户端数量: {len(config.get('clients', {}))}")
+
+ return config
+
+# 运行异步显示
+result = asyncio.run(async_show_config())
+```
+
+### 格式化显示配置
+
+```python
+from mcpstore import MCPStore
+import json
+
+# 初始化
+store = MCPStore.setup_store()
+
+def pretty_show_config(scope="all"):
+ """格式化显示配置"""
+
+ config = store.for_store().show_config(scope)
+
+ print(f"\n=== {scope.upper()} 配置 ===")
+
+ if scope == "all" or scope == "mcp":
+ # 显示服务配置
+ mcp_servers = config.get("mcpServers", {})
+ print(f"\n📦 MCP服务 ({len(mcp_servers)} 个):")
+ for name, cfg in mcp_servers.items():
+ if "url" in cfg:
+ print(f" 🌐 {name}: {cfg['url']}")
+ elif "command" in cfg:
+ print(f" ⚡ {name}: {cfg['command']} {' '.join(cfg.get('args', []))}")
+
+ if scope == "all" or scope == "agent":
+ # 显示Agent配置
+ agents = config.get("agents", {})
+ print(f"\n🤖 Agent配置 ({len(agents)} 个):")
+ for agent_id, agent_cfg in agents.items():
+ print(f" {agent_id}: {len(agent_cfg.get('services', []))} 个服务")
+
+ if scope == "all" or scope == "client":
+ # 显示客户端配置
+ clients = config.get("clients", {})
+ print(f"\n🔗 客户端配置 ({len(clients)} 个):")
+ for client_id, client_cfg in clients.items():
+ print(f" {client_id}: {client_cfg}")
+
+ return config
+
+# 使用格式化显示
+pretty_show_config("all")
+```
+
+### 配置对比
+
+```python
+from mcpstore import MCPStore
+import json
+
+# 初始化
+store = MCPStore.setup_store()
+
+def compare_configs():
+ """对比不同范围的配置"""
+
+ # 获取不同范围的配置
+ all_config = store.for_store().show_config("all")
+ mcp_config = store.for_store().show_config("mcp")
+ agent_config = store.for_store().show_config("agent")
+ client_config = store.for_store().show_config("client")
+
+ print("配置统计对比:")
+ print(f" 完整配置大小: {len(json.dumps(all_config))} 字符")
+ print(f" MCP服务数量: {len(mcp_config.get('mcpServers', {}))}")
+ print(f" Agent数量: {len(agent_config.get('agents', {}))}")
+ print(f" 客户端数量: {len(client_config.get('clients', {}))}")
+
+ return {
+ "all": all_config,
+ "mcp": mcp_config,
+ "agent": agent_config,
+ "client": client_config
+ }
+
+# 执行配置对比
+configs = compare_configs()
+```
+
+### 配置验证
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+def validate_config():
+ """验证配置完整性"""
+
+ config = store.for_store().show_config("all")
+
+ # 验证MCP服务配置
+ mcp_servers = config.get("mcpServers", {})
+ valid_services = 0
+ invalid_services = []
+
+ for name, cfg in mcp_servers.items():
+ if "url" in cfg or "command" in cfg:
+ valid_services += 1
+ else:
+ invalid_services.append(name)
+
+ print(f"配置验证结果:")
+ print(f" 有效服务: {valid_services} 个")
+ print(f" 无效服务: {len(invalid_services)} 个")
+
+ if invalid_services:
+ print(f" 无效服务列表: {invalid_services}")
+
+ # 验证Agent配置
+ agents = config.get("agents", {})
+ print(f" Agent配置: {len(agents)} 个")
+
+ # 验证客户端配置
+ clients = config.get("clients", {})
+ print(f" 客户端配置: {len(clients)} 个")
+
+ return len(invalid_services) == 0
+
+# 执行配置验证
+is_valid = validate_config()
+print(f"配置整体有效性: {'✅ 有效' if is_valid else '❌ 无效'}")
+```
+
+### 配置导出
+
+```python
+from mcpstore import MCPStore
+import json
+from datetime import datetime
+
+# 初始化
+store = MCPStore.setup_store()
+
+def export_config(scope="all", filename=None):
+ """导出配置到文件"""
+
+ # 获取配置
+ config = store.for_store().show_config(scope)
+
+ # 生成文件名
+ if not filename:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"mcpstore_config_{scope}_{timestamp}.json"
+
+ # 导出到文件
+ try:
+ with open(filename, 'w', encoding='utf-8') as f:
+ json.dump(config, f, indent=2, ensure_ascii=False)
+
+ print(f"配置已导出到: {filename}")
+ print(f"配置大小: {len(json.dumps(config))} 字符")
+
+ return filename
+
+ except Exception as e:
+ print(f"导出失败: {e}")
+ return None
+
+# 导出不同范围的配置
+export_config("all")
+export_config("mcp")
+export_config("agent")
+```
+
+## 配置结构说明
+
+### 完整配置结构 (`"all"`)
+```python
+{
+ "mcpServers": {
+ "service_name": {
+ "url": "https://api.example.com/mcp",
+ "transport": "http"
+ }
+ },
+ "agents": {
+ "agent_id": {
+ "services": ["service1", "service2"]
+ }
+ },
+ "clients": {
+ "client_id": {
+ "service_mapping": {...}
+ }
+ }
+}
+```
+
+## 相关方法
+
+- [reset_config()](reset-config.md) - 重置配置
+- [add_service()](../registration/add-service.md) - 添加服务配置
+- [list_services()](../listing/list-services.md) - 查看服务列表
+
+## 注意事项
+
+1. **敏感信息**: 配置可能包含API密钥等敏感信息,注意保护
+2. **实时数据**: 返回的是当前实时配置,不是缓存数据
+3. **Agent隔离**: Agent模式下只显示该Agent相关的配置
+4. **格式一致**: 返回格式与配置文件格式保持一致
+5. **范围选择**: 根据需要选择合适的显示范围,避免信息过载
diff --git a/docs/docs/services/update-service.md b/docs/docs/services/update-service.md
new file mode 100644
index 00000000..74cc11d0
--- /dev/null
+++ b/docs/docs/services/update-service.md
@@ -0,0 +1,234 @@
+## update_service - 服务更新
+
+
+完全替换服务配置。
+
+### SDK
+
+同步:
+ - `store.for_store().update_service(name, config) -> bool`
+ - `store.for_agent(id).update_service(name, config) -> bool`
+
+异步:
+ - `await store.for_store().update_service_async(name, config) -> bool`
+ - `await store.for_agent(id).update_service_async(name, config) -> bool`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| `name` | str | 服务名称 |
+| `config` | dict | 新的服务配置(完全替换) |
+
+### 返回值
+
+- `True`:更新成功
+- `False`:更新失败
+
+### 视角
+通过 `for_store()` 或 `for_agent(id)` 在对应命名空间内更新指定服务。
+
+### 使用示例
+
+### Store级别更新服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 完全替换服务配置
+new_config = {
+ "url": "https://api.newweather.com/mcp",
+ "transport": "http",
+ "timeout": 30,
+ "headers": {
+ "Authorization": "Bearer new-token"
+ }
+}
+
+success = store.for_store().update_service("weather", new_config)
+if success:
+ print("Weather服务配置已更新")
+else:
+ print("Weather服务配置更新失败")
+```
+
+### Agent级别更新服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# Agent模式更新服务
+new_config = {
+ "command": "python",
+ "args": ["weather_server.py", "--port", "8080"],
+ "env": {
+ "API_KEY": "new-api-key"
+ }
+}
+
+success = store.for_agent("agent1").update_service("weather-local", new_config)
+if success:
+ print("Agent Weather服务配置已更新")
+```
+
+### 更新URL服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 更新URL类型服务
+url_config = {
+ "url": "https://api.upgraded-weather.com/mcp",
+ "transport": "http",
+ "headers": {
+ "User-Agent": "MCPStore/1.0",
+ "API-Version": "v2"
+ },
+ "timeout": 60
+}
+
+success = store.for_store().update_service("weather", url_config)
+print(f"URL服务更新: {'成功' if success else '失败'}")
+```
+
+### 更新命令服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 更新命令类型服务
+command_config = {
+ "command": "node",
+ "args": ["weather-server.js", "--config", "production.json"],
+ "cwd": "/opt/weather-service",
+ "env": {
+ "NODE_ENV": "production",
+ "LOG_LEVEL": "info"
+ }
+}
+
+success = store.for_store().update_service("weather", command_config)
+print(f"命令服务更新: {'成功' if success else '失败'}")
+```
+
+### 异步版本
+
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_update_service():
+ # 初始化
+ store = MCPStore.setup_store()
+
+ # 新配置
+ new_config = {
+ "url": "https://api.async-weather.com/mcp",
+ "transport": "websocket",
+ "reconnect": True
+ }
+
+ # 异步更新服务
+ success = await store.for_store().update_service_async("weather", new_config)
+
+ if success:
+ print("异步更新成功")
+ # 验证更新结果
+ service_info = await store.for_store().get_service_info_async("weather")
+ print(f"更新后的服务信息: {service_info}")
+
+ return success
+
+# 运行异步更新
+result = asyncio.run(async_update_service())
+```
+
+### 批量更新服务
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 批量更新多个服务
+services_to_update = {
+ "weather": {
+ "url": "https://api.weather-v2.com/mcp",
+ "timeout": 30
+ },
+ "database": {
+ "command": "python",
+ "args": ["db_server.py", "--version", "2.0"]
+ }
+}
+
+for service_name, config in services_to_update.items():
+ success = store.for_store().update_service(service_name, config)
+ print(f"更新 {service_name}: {'成功' if success else '失败'}")
+```
+
+## 配置格式
+
+### URL服务配置
+```python
+{
+ "url": "https://api.example.com/mcp",
+ "transport": "http|websocket",
+ "headers": {"key": "value"},
+ "timeout": 30,
+ "reconnect": True
+}
+```
+
+### 命令服务配置
+```python
+{
+ "command": "executable",
+ "args": ["arg1", "arg2"],
+ "cwd": "/working/directory",
+ "env": {"VAR": "value"}
+}
+```
+
+## 与 patch_service() 的区别
+
+| 特性 | update_service() | patch_service() |
+|------|------------------|-----------------|
+| 更新方式 | 完全替换 | 增量更新 |
+| 原有配置 | 会被清除 | 会被保留 |
+| 使用场景 | 重大配置变更 | 小幅调整 |
+| 安全性 | 需要完整配置 | 更安全 |
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|------------------|----------|
+| 增量更新服务 | `store.for_store().patch_service(name, patch)` |
+| 重启服务 | `store.for_store().restart_service(name)` |
+| 获取服务信息 | `store.for_store().get_service_info(name)` |
+| 获取服务状态 | `store.for_store().get_service_status(name)` |
+| 删除服务 | `store.for_store().delete_service(name)` |
+| 注册服务 | `store.for_store().add_service(config=..., ...)` |
+| 查找服务 | `store.for_store().find_service(name)` |
+| Agent 更新 | `store.for_agent(id).update_service(name, config)` |
+
+## 注意事项
+
+- 完全替换:会清除所有原有配置,只保留新配置
+- 服务重启:更新配置后服务可能自动重启
+- 配置验证:新配置会进行格式验证
+- Agent 映射:Agent 模式下自动处理服务名映射
+- 推荐:小幅修改建议使用 `patch_service()`
diff --git a/docs/docs/session/sessions.md b/docs/docs/session/sessions.md
new file mode 100644
index 00000000..65fb5c6e
--- /dev/null
+++ b/docs/docs/session/sessions.md
@@ -0,0 +1,154 @@
+## Session - 会话
+
+
+会话用于在多次工具调用之间保持服务状态(如浏览器页面、登录态、长连接等),避免每次调用新建/关闭连接带来的状态丢失与性能浪费。MCPStore 提供稳定、易用的会话管理,适用于 Store 与 Agent 两种上下文。
+
+### SDK
+
+同步:
+ - `for_store().with_session(session_id) -> Session(cm)`
+ - `for_agent(id).with_session(session_id) -> Session(cm)`
+ - `for_store().create_session(session_id) -> Session`
+ - `for_store().session_auto() / session_manual()`
+
+异步:
+ - `await for_store().with_session_async(session_id) -> Session(cm)`
+ - `await for_agent(id).with_session_async(session_id) -> Session(cm)`
+ - `await session.bind_service_async(name)` / `await session.use_tool_async(name, args)`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|---------------|------|------|
+| `session_id` | str | 会话标识;用于关联与复用同一次会话上下文。 |
+| `name` | str | 服务或工具名称;用于绑定或调用时指定目标。 |
+| `seconds` | int | `extend_session(seconds)` 的扩展秒数。 |
+
+### 返回值
+
+- `with_session(...)`:返回可用作上下文管理器的 `Session` 对象。
+- `create_session(...)`:返回 `Session` 对象(手动管理生命周期)。
+
+
+### 视角
+通过 `for_store()` 在全局空间下创建与使用会话;通过 `for_agent(id)` 在隔离的 Agent 空间内创建与使用会话(服务/工具/会话按 `agent_id` 隔离,互不影响)。
+
+
+### 常见打开方式
+
+同步上下文管理器(推荐):
+```python
+store = MCPStore.setup_store()
+store.for_store().add_service({"name":"browser","url":"http://127.0.0.1:8931/sse"})
+store.for_store().wait_service("browser")
+
+with store.for_store().with_session("browser_task") as s:
+ s.bind_service("browser")
+ s.use_tool("browser_navigate", {"url": "https://baidu.com"})
+```
+
+异步上下文管理器:
+```python
+async with store.for_store().with_session_async("browser_task") as s:
+ await s.bind_service_async("browser")
+ await s.use_tool_async("browser_navigate", {"url": "https://baidu.com"})
+```
+
+自动会话(透明复用):
+```python
+store.for_store().session_auto()
+store.for_store().use_tool("browser_navigate", {"url": "https://baidu.com"})
+store.for_store().use_tool("browser_screenshot", {})
+store.for_store().session_manual()
+```
+
+显式 Session 对象(精确控制):
+```python
+session = store.for_store().create_session("langchain_browser")
+session.bind_service("browser")
+session.use_tool("browser_navigate", {"url": "https://baidu.com"})
+session.close_session()
+```
+
+
+### Session 常用方法
+
+- 基础属性:`session.session_id`、`session.is_active`、`session.service_count`、`session.tool_count`
+- 信息查询:`session.session_info()`、`session.list_services()`、`session.list_tools()`、`session.connection_status()`
+- 使用与管理:`session.bind_service(name)`、`session.use_tool(name, args)`、`session.restart_session()`、`session.extend_session(seconds=3600)`、`session.clear_cache()`、`session.close_session()`
+
+
+### Store 与 Agent 上下文
+
+Store 模式(全局共享):
+```python
+with store.for_store().with_session("store_browser") as s:
+ s.bind_service("browser")
+ s.use_tool("browser_navigate", {"url": "https://baidu.com"})
+```
+
+Agent 模式(隔离空间):
+```python
+agent = store.for_agent("team_1")
+with agent.with_session("team1_browser") as s:
+ s.bind_service("browser")
+ s.use_tool("browser_navigate", {"url": "https://baidu.com"})
+```
+
+
+### 与 LangChain 集成(隐式会话路由)
+
+- 在 `with_session(...)` 作用域内调用 `for_langchain().list_tools()`,会自动返回绑定当前会话的工具集合。
+- 在 `session_auto()` 模式下,直接 `for_langchain().list_tools()` 也会走自动会话。
+
+示例(同步):
+```python
+with store.for_store().with_session("langchain_browser"):
+ tools = store.for_store().for_langchain().list_tools()
+ agent = create_tool_calling_agent(llm, tools, prompt)
+ AgentExecutor(agent=agent, tools=tools).invoke({"input": "打开百度并截图"})
+```
+
+示例(自动会话):
+```python
+store.for_store().session_auto()
+tools = store.for_store().for_langchain().list_tools()
+agent = create_tool_calling_agent(llm, tools, prompt)
+AgentExecutor(agent=agent, tools=tools).invoke({"input": "打开百度并截图"})
+```
+
+
+### 使用场景
+
+- 需要跨多次调用保留服务运行态(浏览器页面、登录态、连接等)。
+- 长事务或多步操作串联调用的连贯性保证。
+- Agent 场景中将不同任务隔离在不同会话内并行执行。
+
+
+### 实用提示
+
+- 在 `add_service(...)` 后使用 `wait_service(name)` 确认服务已就绪。
+- 在 `with_session` 作用域内再获取工具,确保工具绑定当前会话。
+- 并发与多任务:为不同任务使用不同 `session_id`,或分别进入独立的 `with_session` 作用域。
+- 清理日志:偶发的 “Failed to close current client ...” 多为无害清理失败,连接会自动重建。
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|------------------|----------|
+| 开启自动会话 | `for_store().session_auto()` |
+| 关闭自动会话 | `for_store().session_manual()` |
+| 创建会话对象 | `for_store().create_session(session_id)` |
+| 会话内列工具 | `for_store().for_langchain().list_tools()` |
+| 绑定服务 | `session.bind_service(name)` |
+| 使用工具 | `session.use_tool(name, args)` |
+
+
+### 相关文档
+
+- 入门 · 使用模式: `getting-started/usage-modes.md`
+- 工具使用总览: `tools/overview.md`
+- 服务管理总览: `services/overview.md`
+- 设计来源与完整方案:项目根目录《会话重构计划.md`
+
diff --git a/docs/docs/store/get-info.md b/docs/docs/store/get-info.md
new file mode 100644
index 00000000..b96cab81
--- /dev/null
+++ b/docs/docs/store/get-info.md
@@ -0,0 +1,5 @@
+## get_info()
+
+Placeholder page for store.for_store().get_info().
+
+
diff --git a/docs/docs/store/list-agents.md b/docs/docs/store/list-agents.md
new file mode 100644
index 00000000..32cf587e
--- /dev/null
+++ b/docs/docs/store/list-agents.md
@@ -0,0 +1,5 @@
+## list_agents()
+
+Placeholder page for store.for_store().list_agents().
+
+
diff --git a/docs/docs/store/overview.md b/docs/docs/store/overview.md
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/docs/tools/call-tool.md b/docs/docs/tools/call-tool.md
new file mode 100644
index 00000000..b6c17042
--- /dev/null
+++ b/docs/docs/tools/call-tool.md
@@ -0,0 +1,356 @@
+## call_tool - 工具调用
+
+
+MCPStore 推荐的工具调用方法,兼容 FastMCP 命名与能力,支持多种工具名格式、参数处理与完善的错误处理。
+
+### SDK
+
+同步:
+ - `store.for_store().call_tool(tool_name, args=None, return_extracted=False, **kwargs) -> Any`
+ - `store.for_agent(id).call_tool(tool_name, args=None, return_extracted=False, **kwargs) -> Any`
+
+异步:
+ - `await store.for_store().call_tool_async(tool_name, args=None, return_extracted=False, **kwargs) -> Any`
+ - `await store.for_agent(id).call_tool_async(tool_name, args=None, return_extracted=False, **kwargs) -> Any`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|--------------------|-------------------------|------|
+| `tool_name` | str | 工具名称,支持多种格式(见“工具名称解析”)。 |
+| `args` | dict 或 str | 工具参数;同步版本支持字典或 JSON 字符串;异步版本使用字典。 |
+| `return_extracted` | bool | 是否提取返回数据;True 返回提取后的数据,False 返回完整结果对象。 |
+| `timeout` | float | 超时时间(秒),通过 `**kwargs` 传入。 |
+| `progress_handler` | Callable[[Any], None] | 进度回调,通过 `**kwargs` 传入。 |
+| `raise_on_error` | bool | 发生错误时是否抛出异常(默认 True),通过 `**kwargs` 传入。 |
+| `session_id` | str | 会话 ID(可选),通过 `**kwargs` 传入。 |
+
+### 返回值
+
+- 类型:`Any`
+- 说明:
+ - 当 `return_extracted=False`:返回完整的 FastMCP CallToolResult 对象(含元数据)。
+ - 当 `return_extracted=True`:返回提取后的数据(自动提取 content/text/data)。
+
+
+### 视角
+通过 `for_store()` 调用全局工具(使用全名)。通过 `for_agent(id)` 在 Agent 空间内调用(支持本地工具名,自动映射为全局名称)。
+
+
+### 工具名称解析
+
+支持的格式:
+- 直接工具名:`"get_weather"`
+- 服务前缀格式:`"weather-api_get_weather"`
+- 旧格式兼容:`"weather-api.get_weather"`
+- Agent 本地格式:在 Agent 模式下使用本地服务名视角
+
+解析优先级:
+- 精确匹配(当前上下文)
+- 前缀匹配(服务前缀)
+- 模糊匹配(唯一时)
+- 无匹配时返回可用工具建议
+
+
+### 使用示例
+
+基础工具调用:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 调用天气查询工具
+result = store.for_store().call_tool(
+ "weather-api_get_current",
+ {"location": "北京"}
+)
+print("天气查询结果:", result)
+
+# 调用无参数工具
+result = store.for_store().call_tool("system_info_get_time")
+print("系统时间:", result)
+
+# 使用 JSON 字符串参数(同步版本支持)
+result = store.for_store().call_tool(
+ "maps-api_search_location",
+ '{"query": "天安门", "limit": 5}'
+)
+print("地点搜索结果:", result)
+```
+
+Agent 模式调用:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+agent_id = "research_agent"
+
+# 使用本地工具名(自动映射为全局名称)
+result = store.for_agent(agent_id).call_tool(
+ "weather-api_get_current",
+ {"location": "上海"}
+)
+print(f"Agent {agent_id} 天气查询:", result)
+
+# 调用多个工具
+tools_to_call = [
+ ("weather-api_get_current", {"location": "广州"}),
+ ("maps-api_search_location", {"query": "珠江"}),
+ ("calculator_add", {"a": 10, "b": 20})
+]
+
+for tool_name, args in tools_to_call:
+ try:
+ result = store.for_agent(agent_id).call_tool(tool_name, args)
+ print(tool_name, "调用成功:", result)
+ except Exception as e:
+ print(tool_name, "调用失败:", e)
+```
+
+高级参数与额外选项:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+complex_args = {
+ "location": {"lat": 39.9042, "lng": 116.4074},
+ "options": {"units": "metric", "lang": "zh-CN", "include_forecast": True},
+ "filters": ["temperature", "humidity", "wind"]
+}
+
+detail = store.for_store().call_tool(
+ "weather-api_get_detailed",
+ complex_args
+)
+
+# 使用额外参数(超时与进度回调)
+processed = store.for_store().call_tool(
+ "slow-service_process_data",
+ {"data": "large_dataset"},
+ timeout=30.0,
+ progress_handler=lambda p: print(f"进度: {p}%")
+)
+```
+
+错误处理与重试:
+```python
+from mcpstore import MCPStore
+import time
+
+store = MCPStore.setup_store()
+
+def call_tool_with_retry(tool_name, args, max_retries=3):
+ for attempt in range(max_retries):
+ try:
+ return store.for_store().call_tool(tool_name, args, timeout=10.0)
+ except Exception as e:
+ if attempt == max_retries - 1:
+ raise
+ time.sleep(2 ** attempt) # 指数退避
+
+try:
+ result = call_tool_with_retry("unreliable-service_process", {"input": "test_data"})
+ print("重试成功:", result)
+except Exception as e:
+ print("最终失败:", e)
+
+# 不抛出异常的调用
+result = store.for_store().call_tool(
+ "might-fail_operation",
+ {"param": "value"},
+ raise_on_error=False
+)
+print("调用结果:", result)
+```
+
+批量工具调用:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+tool_calls = [
+ ("weather-api_get_current", {"location": "北京"}),
+ ("weather-api_get_current", {"location": "上海"}),
+ ("weather-api_get_current", {"location": "广州"}),
+ ("weather-api_get_current", {"location": "深圳"})
+]
+
+results = []
+for tool_name, args in tool_calls:
+ try:
+ out = store.for_store().call_tool(tool_name, args)
+ results.append({"location": args["location"], "result": out, "success": True})
+ print(args["location"], "查询成功")
+ except Exception as e:
+ results.append({"location": args["location"], "error": str(e), "success": False})
+ print(args["location"], "查询失败", e)
+
+successful = sum(1 for r in results if r["success"])
+print(f"批量调用结果: {successful}/{len(results)} 成功")
+```
+
+异步工具调用与并发:
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_tool_calling():
+ store = MCPStore.setup_store()
+
+ # 单个异步调用
+ result = await store.for_store().call_tool_async(
+ "weather-api_get_current",
+ {"location": "北京"}
+ )
+ print("异步天气查询:", result)
+
+ # 并发调用多个工具
+ cities = ["北京", "上海", "广州", "深圳"]
+ tasks = [
+ store.for_store().call_tool_async("weather-api_get_current", {"location": city})
+ for city in cities
+ ]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for city, r in zip(cities, results):
+ print(city, "结果:", r)
+
+# asyncio.run(async_tool_calling())
+```
+
+链式调用:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 第一步:获取当前位置
+loc = store.for_store().call_tool("location-api_get_current_location")
+if not loc or "lat" not in loc:
+ raise RuntimeError("无法获取当前位置")
+
+# 第二步:根据位置获取天气
+weather = store.for_store().call_tool(
+ "weather-api_get_by_coordinates",
+ {"lat": loc["lat"], "lng": loc["lng"]}
+)
+
+# 第三步:根据天气推荐活动
+activities = store.for_store().call_tool(
+ "recommendation-api_suggest_activities",
+ {"weather": weather.get("condition", "unknown"), "temperature": weather.get("temperature", 20)}
+)
+
+result = {"location": loc, "weather": weather, "activities": activities}
+print(result)
+```
+
+调用监控:
+```python
+from mcpstore import MCPStore
+import time
+
+store = MCPStore.setup_store()
+
+def monitor_tool_call(tool_name, args):
+ start_time = time.time()
+ try:
+ print("开始调用工具:", tool_name)
+ print("参数:", args)
+ result = store.for_store().call_tool(tool_name, args)
+ duration = time.time() - start_time
+ print("调用成功,耗时(秒):", f"{duration:.2f}")
+ return {"success": True, "result": result, "duration": duration, "tool_name": tool_name}
+ except Exception as e:
+ duration = time.time() - start_time
+ print("调用失败,耗时(秒):", f"{duration:.2f}")
+ print("错误:", e)
+ return {"success": False, "error": str(e), "duration": duration, "tool_name": tool_name}
+
+tool_calls = [
+ ("weather-api_get_current", {"location": "北京"}),
+ ("maps-api_search_location", {"query": "故宫"}),
+ ("calculator_multiply", {"a": 123, "b": 456})
+]
+
+results = [monitor_tool_call(name, params) for name, params in tool_calls]
+total_duration = sum(r["duration"] for r in results)
+successful_calls = sum(1 for r in results if r["success"])
+print("总调用数:", len(results))
+print("成功调用:", successful_calls)
+print("失败调用:", len(results) - successful_calls)
+print("总耗时(秒):", f"{total_duration:.2f}")
+print("平均耗时(秒):", f"{(total_duration / len(results)):.2f}")
+```
+
+
+### 返回示例
+
+成功:
+```json
+{
+ "success": true,
+ "data": {
+ "temperature": 22,
+ "condition": "sunny",
+ "humidity": 65,
+ "wind_speed": 5
+ },
+ "metadata": {
+ "execution_time_ms": 1250,
+ "trace_id": "abc12345",
+ "tool_name": "weather-api_get_current",
+ "service_name": "weather-api"
+ },
+ "message": "Tool 'weather-api_get_current' executed successfully in 1250ms"
+}
+```
+
+失败:
+```json
+{
+ "success": false,
+ "data": {
+ "error": "Tool 'non_existent_tool' not found"
+ },
+ "metadata": {
+ "execution_time_ms": 5,
+ "trace_id": "def67890",
+ "tool_name": "non_existent_tool",
+ "service_name": null
+ },
+ "message": "Tool execution failed: Tool 'non_existent_tool' not found"
+}
+```
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|----------------|----------|
+| 兼容调用 | `store.for_store().use_tool(...)` |
+| 获取工具列表 | `store.for_store().list_tools()` |
+| 服务列表 | `store.for_store().list_services()` |
+| 服务信息 | `store.for_store().get_service_info(name)` |
+| 服务状态 | `store.for_store().get_service_status(name)` |
+
+
+### 使用场景
+
+- 统一入口调用远程或本地工具,屏蔽传输细节。
+- Agent 模式下用本地工具名开发与调试。
+- 批量和并发调用场景下的统一封装与监控。
+- 需要返回提取、错误吞吐与重试策略的生产用例。
+
+
+### 注意事项
+
+- 名称解析:在 Agent 模式下支持本地名称,系统自动映射为全局名称。
+- 参数约束:异步版本 `args` 使用字典;同步版本支持字典或 JSON 字符串。
+- 错误处理:`raise_on_error=False` 时不抛异常,请检查返回对象中的错误字段。
+- 性能:密集调用建议设置 `timeout` 并采用异步并发以提升吞吐。
+- 会话:在需要上下文粘性的场景可传入 `session_id`。
+
+
diff --git a/docs/docs/tools/find-tool.md b/docs/docs/tools/find-tool.md
new file mode 100644
index 00000000..1e025c00
--- /dev/null
+++ b/docs/docs/tools/find-tool.md
@@ -0,0 +1,153 @@
+## find_tool - 查找工具
+
+
+查找工具并返回 `ToolProxy` 对象。
+
+### SDK
+
+同步:
+ - `store.for_store().find_tool(tool_name, service_name=None) -> ToolProxy`
+ - `store.for_agent(id).find_tool(tool_name, service_name=None) -> ToolProxy`
+
+异步:
+ - `await store.for_store().find_tool_async(tool_name, service_name=None) -> ToolProxy`
+ - `await store.for_agent(id).find_tool_async(tool_name, service_name=None) -> ToolProxy`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|----------------|------|------|
+| `tool_name` | str | 工具名称,支持多种格式(见“工具名称格式”)。 |
+| `service_name` | str | 指定服务名称(可选)。 |
+
+### 返回值
+
+- 类型:`ToolProxy`
+- 说明:工具代理对象,提供工具详情、配置与调用等操作。
+
+
+### 视角
+通过 `for_store()` 可在全局范围查找任意服务的工具;通过 `for_agent(id)` 仅查找当前 Agent 的工具,名称映射自动处理。
+
+
+### 使用示例
+
+Store 级别查找工具:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+info = tool_proxy.tool_info()
+print("工具信息:", info)
+
+result = tool_proxy.call_tool({"query": "北京"})
+print("调用结果:", getattr(result, "text_output", result))
+```
+
+Agent 级别查找工具:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_agent("agent1").add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_agent("agent1").wait_service("weather")
+
+tool_proxy = store.for_agent("agent1").find_tool("get_current_weather")
+result = tool_proxy.call_tool({"query": "上海"})
+print("Agent 工具调用:", getattr(result, "text_output", result))
+```
+
+指定服务查找:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "weather1": {"url": "https://api1.example.com/mcp"},
+ "weather2": {"url": "https://api2.example.com/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather1")
+store.for_store().wait_service("weather2")
+
+tool_proxy = store.for_store().find_tool(
+ tool_name="get_weather",
+ service_name="weather1"
+)
+print("找到工具:", tool_proxy.tool_info())
+```
+
+工具名称格式:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "my-service": {"url": "https://example.com/mcp"}
+ }
+})
+
+store.for_store().wait_service("my-service")
+
+# 简短名称
+tool1 = store.for_store().find_tool("get_weather")
+
+# 服务前缀(双下划线)
+tool2 = store.for_store().find_tool("my-service__get_weather")
+
+# 服务前缀(单下划线)
+tool3 = store.for_store().find_tool("my-service_get_weather")
+
+print(tool1.tool_info()["name"]) # 一致
+print(tool2.tool_info()["name"]) # 一致
+print(tool3.tool_info()["name"]) # 一致
+```
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|----------------|----------|
+| 列出工具 | `store.for_store().list_tools()` |
+| 获取工具详情 | `tool_proxy.tool_info()` |
+| 调用工具 | `tool_proxy.call_tool(args)` 或 `store.for_store().call_tool(...)` |
+| 设置重定向 | `tool_proxy.set_redirect(True)` |
+| ToolProxy 概念 | 见 `tool-proxy.md` |
+
+
+### 使用场景
+
+- 根据名称快速定位并获取某个工具的代理对象。
+- 在多服务场景下通过 `service_name` 锁定查询范围。
+- 在 Agent 模式下使用本地名称进行开发与调试。
+
+
+### 注意事项
+
+- 名称格式:支持简短名以及带服务前缀的名称(单/双下划线)。
+- 查询范围:`service_name` 可用于限定服务,避免歧义。
+- Agent 隔离:Agent 级别只能查找该 Agent 的工具。
+- ToolProxy:返回对象提供工具详情、调用与配置相关操作。
+
diff --git a/docs/docs/tools/list-tools.md b/docs/docs/tools/list-tools.md
new file mode 100644
index 00000000..c8776b2f
--- /dev/null
+++ b/docs/docs/tools/list-tools.md
@@ -0,0 +1,498 @@
+## list_tools - 工具列表查询
+
+MCPStore 的 `list_tools()` 方法提供完整的工具列表查询功能,支持 **Store/Agent 双模式**,返回详细的 `ToolInfo` 对象,包含工具描述、输入模式和服务归属信息。
+
+### SDK
+
+同步:
+ - `store.for_store().list_tools() -> List[ToolInfo]`
+ - `store.for_agent(id).list_tools() -> List[ToolInfo]`
+
+异步:
+ - `await store.for_store().list_tools_async() -> List[ToolInfo]`
+ - `await store.for_agent(id).list_tools_async() -> List[ToolInfo]`
+
+### ToolInfo 模型
+
+基于真实代码分析,`ToolInfo` 包含以下完整属性:
+
+```python
+class ToolInfo:
+ name: str # 工具名称
+ description: str # 工具描述
+ service_name: str # 所属服务名
+ client_id: Optional[str] # 客户端ID
+ inputSchema: Optional[Dict[str, Any]] # 输入模式(JSON Schema)
+```
+
+### inputSchema 结构
+
+```python
+# 典型的 inputSchema 结构
+{
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "城市名称或坐标"
+ },
+ "units": {
+ "type": "string",
+ "enum": ["celsius", "fahrenheit"],
+ "description": "温度单位"
+ }
+ },
+ "required": ["location"]
+}
+```
+
+### Agent 模式支持
+
+### 支持状态
+- 完全支持 - `list_tools()` 在 Agent 模式下可用,并支持智能等待机制
+
+### Agent 模式调用
+```python
+# Agent 模式调用
+agent_tools = store.for_agent("research_agent").list_tools()
+
+# 异步 Agent 模式调用
+agent_tools = await store.for_agent("research_agent").list_tools_async()
+
+# 对比 Store 模式调用
+store_tools = store.for_store().list_tools()
+```
+
+### 模式差异说明
+- **Store 模式**: 返回所有全局工具,包括带后缀的 Agent 服务工具
+- **Agent 模式**: 只返回当前 Agent 的工具,自动转换为本地名称
+- **主要区别**: Agent 模式提供完全隔离的工具视图,工具名和服务名都是本地视图
+
+### 返回值对比
+
+#### Store 模式返回示例
+```python
+[
+ ToolInfo(
+ name="weather_get_current",
+ service_name="weather-api",
+ client_id="global_agent_store:weather-api"
+ ),
+ ToolInfo(
+ name="maps_search_locationbyagent1",
+ service_name="maps-apibyagent1",
+ client_id="agent1:maps-api"
+ ),
+ ToolInfo(
+ name="calculator_addbyagent2",
+ service_name="calculator-apibyagent2",
+ client_id="agent2:calculator-api"
+ )
+]
+```
+
+#### Agent 模式返回示例
+```python
+# Agent "agent1" 的视图
+[
+ ToolInfo(
+ name="weather_get_current",
+ service_name="weather-api", # 本地服务名
+ client_id="agent1:weather-api"
+ ),
+ ToolInfo(
+ name="maps_search_location", # 本地工具名
+ service_name="maps-api", # 本地服务名
+ client_id="agent1:maps-api"
+ )
+]
+```
+
+### 智能等待机制
+- **Store 模式**: 等待所有服务初始化完成
+- **Agent 模式**: 只等待当前 Agent 的服务初始化
+- **性能优势**: Agent 模式等待时间更短,只关注相关服务
+
+### 使用建议
+- **Agent 开发**: 推荐使用 Agent 模式,获得干净的工具列表
+- **工具管理**: 使用 Store 模式,查看所有工具的全局状态
+- **性能考虑**: Agent 模式在大型系统中性能更好,等待时间更短
+
+## 上下文模式详解
+
+### Store 模式特点
+
+```python
+store.for_store().list_tools()
+```
+
+**核心特点**:
+- 返回所有全局注册的工具
+- 包括带后缀的 Agent 服务工具
+- 显示完整的工具名称和服务名称
+- 跨上下文的工具管理视图
+
+### Agent 模式特点
+
+```python
+store.for_agent(agent_id).list_tools()
+```
+
+**核心特点**:
+- 只返回当前 Agent 的工具
+- 自动转换为本地名称
+- 完全隔离的工具视图
+- 智能等待优化
+
+## 使用示例
+
+### 基础工具列表查询
+
+```python
+from mcpstore import MCPStore
+
+def basic_tool_listing():
+ store = MCPStore.setup_store()
+ tools = store.for_store().list_tools()
+ print(f"总工具数: {len(tools)}")
+ for tool in tools:
+ print(f"- {tool.name}")
+ print(f" 服务: {tool.service_name}")
+ print(f" 描述: {tool.description}")
+ print(f" 客户端ID: {tool.client_id}")
+ if tool.inputSchema and "properties" in tool.inputSchema:
+ properties = tool.inputSchema["properties"]
+ print(f" 参数: {list(properties.keys())}")
+
+basic_tool_listing()
+```
+
+### Agent 级别工具列表
+
+```python
+from mcpstore import MCPStore
+
+def agent_tool_listing():
+ store = MCPStore.setup_store()
+ agent_id = "research_agent"
+ agent_tools = store.for_agent(agent_id).list_tools()
+ print(f"Agent '{agent_id}' 工具数: {len(agent_tools)}")
+ for tool in agent_tools:
+ print(f"- {tool.name}")
+ print(f" 服务: {tool.service_name}")
+ print(f" 客户端ID: {tool.client_id}")
+ print(f" 描述: {tool.description}")
+ if tool.inputSchema and "properties" in tool.inputSchema:
+ print(" 参数详情:")
+ for name, info in tool.inputSchema["properties"].items():
+ t = info.get("type", "unknown")
+ d = info.get("description", "无描述")
+ req = name in tool.inputSchema.get("required", [])
+ mark = " *" if req else ""
+ print(f" - {name}{mark}: {t} - {d}")
+
+agent_tool_listing()
+```
+
+### 按服务分组显示工具
+
+```python
+from mcpstore import MCPStore
+
+def tools_by_service():
+ store = MCPStore.setup_store()
+ tools = store.for_store().list_tools()
+ by_service = {}
+ for tool in tools:
+ by_service.setdefault(tool.service_name, []).append(tool)
+ print("按服务分组的工具列表")
+ print("=" * 50)
+ for service_name, tools_list in by_service.items():
+ print(f"服务: {service_name} ({len(tools_list)})")
+ for tool in tools_list:
+ print(f" - {tool.name}")
+ print(f" 描述: {tool.description}")
+ if tool.inputSchema and "required" in tool.inputSchema:
+ req = tool.inputSchema["required"]
+ if req:
+ print(f" 必需参数: {', '.join(req)}")
+
+tools_by_service()
+```
+
+### 工具详细信息展示
+
+```python
+from mcpstore import MCPStore
+
+def detailed_tool_info():
+ store = MCPStore.setup_store()
+ tools = store.for_store().list_tools()
+ print("工具详细信息")
+ print("=" * 60)
+ for tool in tools:
+ print(f"工具: {tool.name}")
+ print(f" 服务: {tool.service_name}")
+ print(f" 客户端ID: {tool.client_id}")
+ print(f" 描述: {tool.description}")
+ if tool.inputSchema:
+ schema = tool.inputSchema
+ print(" 输入模式:")
+ print(f" 类型: {schema.get('type', 'unknown')}")
+ if "properties" in schema:
+ print(" 参数列表:")
+ props = schema["properties"]
+ required = schema.get("required", [])
+ for name, info in props.items():
+ t = info.get("type", "unknown")
+ d = info.get("description", "无描述")
+ req = name in required
+ print(f" - {name}:")
+ print(f" 类型: {t}")
+ print(f" 必需: {'是' if req else '否'}")
+ print(f" 描述: {d}")
+ if "enum" in info:
+ print(f" 可选值: {info['enum']}")
+ if "default" in info:
+ print(f" 默认值: {info['default']}")
+ else:
+ print(" 输入模式: 无参数")
+ print("-" * 40)
+
+detailed_tool_info()
+```
+
+### 工具统计分析
+
+```python
+from mcpstore import MCPStore
+
+def tool_statistics():
+ store = MCPStore.setup_store()
+ tools = store.for_store().list_tools()
+ service_counts = {}
+ param_counts = {}
+ total_params = 0
+ tools_with_params = 0
+ for tool in tools:
+ s = tool.service_name
+ service_counts[s] = service_counts.get(s, 0) + 1
+ if tool.inputSchema and "properties" in tool.inputSchema:
+ c = len(tool.inputSchema["properties"])
+ param_counts[c] = param_counts.get(c, 0) + 1
+ total_params += c
+ tools_with_params += 1
+ print("工具统计分析")
+ print("=" * 40)
+ print(f"总工具数: {len(tools)}")
+ print(f"服务数: {len(service_counts)}")
+ print(f"有参数的工具: {tools_with_params}")
+ print(f"平均参数数: {total_params / tools_with_params if tools_with_params > 0 else 0:.1f}")
+ print("服务工具分布:")
+ for service, count in sorted(service_counts.items()):
+ pct = count / len(tools) * 100
+ print(f" {service}: {count} ({pct:.1f}%)")
+ print("参数数量分布:")
+ for param_count, tool_count in sorted(param_counts.items()):
+ print(f" {param_count} 个参数: {tool_count} 个工具")
+
+tool_statistics()
+```
+
+### 工具搜索和筛选
+
+```python
+from mcpstore import MCPStore
+
+def search_and_filter_tools():
+ store = MCPStore.setup_store()
+ tools = store.for_store().list_tools()
+ def search_tools(keyword):
+ results = []
+ for tool in tools:
+ if (keyword.lower() in tool.name.lower() or
+ keyword.lower() in tool.description.lower() or
+ keyword.lower() in tool.service_name.lower()):
+ results.append(tool)
+ return results
+ def filter_by_service(service_name):
+ return [tool for tool in tools if tool.service_name == service_name]
+ def filter_by_param_count(min_params=0, max_params=None):
+ results = []
+ for tool in tools:
+ if tool.inputSchema and "properties" in tool.inputSchema:
+ cnt = len(tool.inputSchema["properties"])
+ else:
+ cnt = 0
+ if cnt >= min_params and (max_params is None or cnt <= max_params):
+ results.append(tool)
+ return results
+ print("搜索包含 'weather' 的工具:")
+ weather_tools = search_tools("weather")
+ for tool in weather_tools:
+ print(f" - {tool.name} ({tool.service_name})")
+ print("筛选参数较多的工具 (>= 3个参数):")
+ complex_tools = filter_by_param_count(min_params=3)
+ for tool in complex_tools:
+ cnt = len(tool.inputSchema.get("properties", {}))
+ print(f" - {tool.name}: {cnt} 个参数")
+
+search_and_filter_tools()
+```
+
+### 异步工具列表查询
+
+```python
+import asyncio
+from mcpstore import MCPStore
+
+async def async_tool_listing():
+ store = MCPStore.setup_store()
+ tools = await store.for_store().list_tools_async()
+ print(f"异步获取到 {len(tools)} 个工具")
+ agent_ids = ["agent1", "agent2", "agent3"]
+ tasks = [store.for_agent(a).list_tools_async() for a in agent_ids]
+ agent_tools_list = await asyncio.gather(*tasks)
+ for agent_id, agent_tools in zip(agent_ids, agent_tools_list):
+ print(f"Agent {agent_id}: {len(agent_tools)} 个工具")
+ for tool in agent_tools[:2]:
+ print(f" - {tool.name}")
+
+# asyncio.run(async_tool_listing())
+```
+
+### 工具对比分析
+
+```python
+from mcpstore import MCPStore
+
+def compare_store_vs_agent_tools():
+ store = MCPStore.setup_store()
+ store_tools = store.for_store().list_tools()
+ agent_id = "test_agent"
+ agent_tools = store.for_agent(agent_id).list_tools()
+ print("Store vs Agent 工具对比")
+ print("=" * 50)
+ print(f"Store 级别工具 ({len(store_tools)}):")
+ for tool in store_tools:
+ print(f" - {tool.name} ({tool.service_name})")
+ print(f"Agent '{agent_id}' 工具 ({len(agent_tools)}):")
+ for tool in agent_tools:
+ print(f" - {tool.name} ({tool.service_name})")
+ store_names = {t.name for t in store_tools}
+ agent_names = {t.name for t in agent_tools}
+ print("隔离分析:")
+ print(f" Store 独有工具: {len(store_names - agent_names)} 个")
+ print(f" Agent 独有工具: {len(agent_names - store_names)} 个")
+ print(f" 共同工具: {len(store_names & agent_names)} 个")
+
+compare_store_vs_agent_tools()
+```
+
+## 智能等待机制
+
+MCPStore 实现了智能等待机制,确保工具列表的完整性:
+
+### 等待策略
+
+- **远程服务**: 最多等待 1.5 秒
+- **本地服务**: 最多等待 5 秒
+- **状态确定**: 服务状态确定后立即返回
+- **快速路径**: 无 INITIALIZING 服务时跳过等待
+
+### 实现原理
+
+```python
+# 智能等待逻辑(简化版)
+if has_initializing_services():
+ await wait_for_initializing_services()
+
+# 获取工具列表
+tools = await get_tools_from_cache()
+```
+
+## API 响应格式
+
+### Store API 响应
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "name": "weather_get_current",
+ "description": "获取当前天气信息",
+ "service_name": "weather-api",
+ "client_id": "global_agent_store:weather-api",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "城市名称"
+ }
+ },
+ "required": ["location"]
+ }
+ }
+ ],
+ "metadata": {
+ "total_tools": 1,
+ "services_count": 1
+ },
+ "message": "Retrieved 1 tools from 1 services"
+}
+```
+
+### Agent API 响应
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "name": "weather_get_current",
+ "description": "获取当前天气信息",
+ "service_name": "weather-api",
+ "client_id": "agent1:weather-api",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "城市名称"
+ }
+ },
+ "required": ["location"]
+ }
+ }
+ ],
+ "metadata": {
+ "total_tools": 1,
+ "services_count": 1
+ },
+ "message": "Retrieved 1 tools from 1 services for agent 'agent1'"
+}
+```
+
+### 性能
+
+- **平均耗时**: 0.001秒
+- **缓存机制**: 内存缓存,实时更新
+- **智能等待**: 自动等待服务初始化完成
+- **并发支持**: 支持异步并发查询
+- **数据一致性**: 实时反映工具状态
+
+### 相关文档
+
+- [call_tool()](../usage/call-tool.md) - 工具调用方法
+- [use_tool()](../usage/use-tool.md) - 工具使用方法(兼容别名)
+- [服务列表查询](../../services/listing/list-services.md) - 获取服务列表
+- [工具使用概览](../usage/tool-usage-overview.md) - 工具使用概览
+
+### 下一步
+
+- 学习 [工具调用方法](../usage/call-tool.md)
+- 了解 [工具使用概览](../usage/tool-usage-overview.md)
+- 掌握 [服务列表查询](../../services/listing/list-services.md)
+- 查看 [工具管理操作](../management/tool-management.md)
diff --git a/docs/docs/tools/overview.md b/docs/docs/tools/overview.md
new file mode 100644
index 00000000..0c29451c
--- /dev/null
+++ b/docs/docs/tools/overview.md
@@ -0,0 +1,254 @@
+# 工具管理概览
+
+MCPStore 提供了完整的工具管理功能,按照功能分类为5个核心模块,涵盖从查找到统计的全流程操作。
+
+## 📋 **工具管理5大模块**
+
+### 1. 🔍 **查找工具**
+查找工具并获取工具代理对象或列表。
+
+**核心方法**:
+- **[find_tool()](finding/find-tool.md)** - 查找工具并返回 ToolProxy
+- **[list_tools()](finding/list-tools.md)** - 列出所有可用工具
+
+**相关文档**:
+- [ToolProxy 概念](finding/tool-proxy.md) - 了解工具代理机制
+
+---
+
+### 2. 📊 **工具详情**
+获取工具的详细信息、标签和输入模式。
+
+**核心方法**:
+- **[tool_info()](details/tool-info.md)** - 获取工具详细信息
+- **[tool_tags()](details/tool-tags.md)** - 获取工具标签
+- **[tool_schema()](details/tool-schema.md)** - 获取工具输入模式
+
+> 💡 **提示**: 这些方法需要先通过 `find_tool()` 获取 ToolProxy 对象后调用
+
+---
+
+### 3. 🚀 **使用工具**
+调用工具执行操作。
+
+**核心方法**:
+- **[call_tool()](usage/call-tool.md)** - 调用指定工具(推荐)
+- **[use_tool()](usage/use-tool.md)** - 调用工具的向后兼容别名
+
+**使用方式**:
+- **Context 级别**: `store.for_store().call_tool("tool_name", args)`
+- **ToolProxy 级别**: `tool_proxy.call_tool(args)`
+
+---
+
+### 4. ⚙️ **工具配置**
+配置工具行为,如设置重定向标记。
+
+**核心方法**:
+- **[set_redirect()](config/set-redirect.md)** - 设置工具重定向标记(用于 LangChain return_direct)
+
+**应用场景**:
+- LangChain 集成
+- 直接返回工具结果
+- 跳过 Agent 后处理
+
+---
+
+### 5. 📈 **工具统计**
+获取工具的使用统计和调用历史。
+
+**核心方法**:
+- **[usage_stats()](stats/usage-stats.md)** - 获取工具使用统计(ToolProxy)
+- **[call_history()](stats/call-history.md)** - 获取工具调用历史(ToolProxy)
+- **[tools_stats()](stats/tools-stats.md)** - 获取服务工具统计(ServiceProxy)
+
+**对比**:
+| 方法 | 调用层级 | 统计范围 |
+|------|----------|----------|
+| usage_stats() | ToolProxy | 单个工具 |
+| call_history() | ToolProxy | 单个工具 |
+| tools_stats() | ServiceProxy | 服务所有工具 |
+
+---
+
+## 🎯 **快速开始**
+
+### 完整的工具管理流程
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 1️⃣ 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 2️⃣ 等待服务就绪
+store.for_store().wait_service("weather")
+
+# 3️⃣ 列出所有工具
+tools = store.for_store().list_tools()
+print(f"可用工具: {[t.name for t in tools]}")
+
+# 4️⃣ 查找特定工具
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+# 5️⃣ 获取工具详情
+info = tool_proxy.tool_info()
+print(f"工具信息: {info['description']}")
+
+# 6️⃣ 设置工具配置(可选)
+tool_proxy.set_redirect(True)
+
+# 7️⃣ 调用工具
+result = tool_proxy.call_tool({"query": "北京"})
+print(f"调用结果: {result.text_output}")
+
+# 8️⃣ 查看统计
+stats = tool_proxy.usage_stats()
+print(f"调用次数: {stats['call_count']}")
+
+# 9️⃣ 查看历史
+history = tool_proxy.call_history(limit=5)
+print(f"最近{len(history)}次调用")
+```
+
+### Store vs Agent 模式
+
+MCPStore 支持两种工具管理模式:
+
+```python
+# Store 级别(全局共享)
+tools = store.for_store().list_tools()
+tool = store.for_store().find_tool("get_weather")
+result = store.for_store().call_tool("get_weather", {"query": "北京"})
+
+# Agent 级别(独立隔离)
+tools = store.for_agent("agent1").list_tools()
+tool = store.for_agent("agent1").find_tool("get_weather")
+result = store.for_agent("agent1").call_tool("get_weather", {"query": "上海"})
+```
+
+| 特性 | Store 级别 | Agent 级别 |
+|------|------------|------------|
+| **访问范围** | 全局工具 | Agent工具 |
+| **工具隔离** | 无隔离 | 完全隔离 |
+| **适用场景** | 通用工具调用 | Agent专用工具 |
+
+---
+
+## 🎭 **调用层级说明**
+
+MCPStore 的工具方法分为三个调用层级:
+
+### Context 层级
+通过 `store.for_store()` 或 `store.for_agent()` 调用:
+
+```python
+# Context 层级方法
+store.for_store().find_tool("tool_name") # 查找工具
+store.for_store().list_tools() # 列出工具
+store.for_store().call_tool("name", args) # 调用工具
+store.for_store().use_tool("name", args) # 调用工具别名
+```
+
+### ToolProxy 层级
+通过 `find_tool()` 返回的代理对象调用:
+
+```python
+# ToolProxy 层级方法
+tool_proxy = store.for_store().find_tool("tool_name")
+
+tool_proxy.tool_info() # 工具详情
+tool_proxy.tool_tags() # 工具标签
+tool_proxy.tool_schema() # 工具模式
+tool_proxy.set_redirect(True) # 设置重定向
+tool_proxy.call_tool(args) # 调用工具
+tool_proxy.usage_stats() # 使用统计
+tool_proxy.call_history() # 调用历史
+```
+
+### ServiceProxy 层级
+通过 `find_service()` 返回的服务代理对象调用:
+
+```python
+# ServiceProxy 层级方法
+svc = store.for_store().find_service("service_name")
+
+svc.list_tools() # 列出服务的工具
+svc.tools_stats() # 服务工具统计
+```
+
+---
+
+## 📊 **方法速查表**
+
+| 功能 | 方法 | 调用层级 | 文档 |
+|------|------|----------|------|
+| **查找** | find_tool() | Context | [查看](finding/find-tool.md) |
+| **列表** | list_tools() | Context / ServiceProxy | [查看](finding/list-tools.md) |
+| **详情** | tool_info() | ToolProxy | [查看](details/tool-info.md) |
+| **标签** | tool_tags() | ToolProxy | [查看](details/tool-tags.md) |
+| **模式** | tool_schema() | ToolProxy | [查看](details/tool-schema.md) |
+| **调用** | call_tool() | Context / ToolProxy | [查看](usage/call-tool.md) |
+| **别名** | use_tool() | Context | [查看](usage/use-tool.md) |
+| **配置** | set_redirect() | ToolProxy | [查看](config/set-redirect.md) |
+| **统计** | usage_stats() | ToolProxy | [查看](stats/usage-stats.md) |
+| **历史** | call_history() | ToolProxy | [查看](stats/call-history.md) |
+| **服务统计** | tools_stats() | ServiceProxy | [查看](stats/tools-stats.md) |
+
+---
+
+## 💡 **核心概念**
+
+### ToolProxy
+ToolProxy 是工具代理对象,类似于 ServiceProxy,提供工具级别的操作方法。
+
+```python
+# 获取 ToolProxy
+tool_proxy = store.for_store().find_tool("tool_name")
+
+# ToolProxy 提供的方法
+tool_proxy.tool_info() # 详情
+tool_proxy.tool_tags() # 标签
+tool_proxy.tool_schema() # 模式
+tool_proxy.set_redirect() # 配置
+tool_proxy.call_tool() # 调用
+tool_proxy.usage_stats() # 统计
+tool_proxy.call_history() # 历史
+```
+
+详见:[ToolProxy 概念](finding/tool-proxy.md)
+
+### 工具名称格式
+MCPStore 支持多种工具名称格式:
+
+```python
+# 1. 简短名称
+tool = store.for_store().find_tool("get_weather")
+
+# 2. 服务前缀(双下划线)
+tool = store.for_store().find_tool("weather__get_weather")
+
+# 3. 服务前缀(单下划线)
+tool = store.for_store().find_tool("weather_get_weather")
+```
+
+---
+
+## 🔗 **相关文档**
+
+- [服务管理概览](../services/overview.md) - 了解服务管理
+- [ServiceProxy 概念](../services/listing/service-proxy.md) - 理解服务代理
+- [ToolProxy 概念](finding/tool-proxy.md) - 理解工具代理
+- [最佳实践](../advanced/best-practices.md) - 工具使用最佳实践
+
+---
+
+**更新时间**: 2025-01-09
+**版本**: 2.0.0
diff --git a/docs/docs/tools/tool-info.md b/docs/docs/tools/tool-info.md
new file mode 100644
index 00000000..7c013bae
--- /dev/null
+++ b/docs/docs/tools/tool-info.md
@@ -0,0 +1,128 @@
+## tool_info - 工具信息
+
+
+获取工具的详细信息。
+
+### SDK
+
+同步:
+ - `tool_proxy.tool_info() -> Dict[str, Any]`
+
+异步:
+ - `await tool_proxy.tool_info_async() -> Dict[str, Any]`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| 无 | - | 该方法不需要参数。 |
+
+### 返回值
+
+- 类型:`Dict[str, Any]`
+- 字段:
+
+| 字段 | 类型 | 说明 |
+|----------------|------|------|
+| `name` | str | 工具名称 |
+| `description` | str | 工具描述 |
+| `service_name` | str | 所属服务名 |
+| `client_id` | str | 客户端标识 |
+| `inputSchema` | dict | 输入模式(JSON Schema) |
+
+
+### 视角
+在通过 `find_tool()` 获取的 `ToolProxy` 上调用。支持 Store 级与 Agent 级:`tool_proxy = store.for_store().find_tool(name)` 或 `store.for_agent(agent_id).find_tool(name)`。
+
+
+### 使用示例
+
+Store 级获取工具信息:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+info = tool_proxy.tool_info()
+print("工具名称:", info["name"])
+print("工具描述:", info["description"])
+print("所属服务:", info["service_name"])
+print("输入模式:", info["inputSchema"])
+```
+
+Agent 级获取工具信息:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_agent("agent1").add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_agent("agent1").wait_service("weather")
+tool_proxy = store.for_agent("agent1").find_tool("get_current_weather")
+
+info = tool_proxy.tool_info()
+print("Agent 工具信息:", info)
+```
+
+查看输入模式(Schema):
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+info = tool_proxy.tool_info()
+schema = info["inputSchema"]
+print("输入类型:", schema.get("type"))
+print("必需参数:", schema.get("required", []))
+properties = schema.get("properties", {})
+for param_name, param_def in properties.items():
+ print(param_name, param_def.get("type"), param_def.get("description"))
+```
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|----------------|----------|
+| 获取工具标签 | `tool_proxy.tool_tags()` |
+| 获取输入模式 | `tool_proxy.tool_schema()` |
+| 查找工具 | `store.for_store().find_tool(name)` |
+| 列出工具 | `store.for_store().list_tools()` |
+
+
+### 使用场景
+
+- 展示层渲染工具详情与参数说明。
+- 调用前读取 `inputSchema` 做参数校验与表单生成。
+- Agent 开发时核对本地可见的工具元信息。
+
+
+### 注意事项
+
+- 调用前提:需先通过 `find_tool()` 获取 `ToolProxy`。
+- 信息来源:数据来自服务注册的工具定义,可能与实时实现略有不同。
+- Schema:`inputSchema` 符合 JSON Schema 约定,字段可能为空请做好判空。
+
diff --git a/docs/docs/tools/tool-proxy.md b/docs/docs/tools/tool-proxy.md
new file mode 100644
index 00000000..15c1c027
--- /dev/null
+++ b/docs/docs/tools/tool-proxy.md
@@ -0,0 +1,164 @@
+## ToolProxy - 工具代理
+
+ToolProxy 是 MCPStore 中的工具代理对象,提供工具级别的操作方法。
+
+### 概述
+
+ToolProxy 类似于 ServiceProxy,是通过 `find_tool()` 返回的代理对象,封装了对单个工具的所有操作。
+
+### 获取 ToolProxy
+
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("weather")
+
+# 获取 ToolProxy
+tool_proxy = store.for_store().find_tool("get_current_weather")
+```
+
+### ToolProxy 提供的方法
+
+#### 工具详情查询
+
+```python
+# 获取工具详细信息
+info = tool_proxy.tool_info()
+print(f"工具名称: {info['name']}")
+print(f"工具描述: {info['description']}")
+print(f"所属服务: {info['service_name']}")
+
+# 获取工具标签
+tags = tool_proxy.tool_tags()
+print(f"工具标签: {tags}")
+
+# 获取工具输入模式(JSON Schema)
+schema = tool_proxy.tool_schema()
+print(f"输入模式: {schema}")
+```
+
+#### 工具配置
+
+```python
+# 设置重定向标记(用于 LangChain return_direct)
+tool_proxy.set_redirect(True)
+```
+
+#### 工具调用
+
+```python
+# 调用工具
+result = tool_proxy.call_tool({"query": "北京天气"})
+print(f"调用结果: {result.text_output}")
+print(f"是否出错: {result.is_error}")
+```
+
+#### 工具统计
+
+```python
+# 获取使用统计
+stats = tool_proxy.usage_stats()
+print(f"调用次数: {stats['call_count']}")
+print(f"平均耗时: {stats['avg_duration']}")
+
+# 获取调用历史
+history = tool_proxy.call_history(limit=10)
+for record in history:
+ print(f"调用时间: {record['called_at']}")
+ print(f"参数: {record['arguments']}")
+ print(f"结果: {record['result']}")
+```
+
+### Store vs Agent 模式
+
+#### Store 模式
+```python
+# Store 级别的 ToolProxy
+tool_proxy = store.for_store().find_tool("get_weather")
+
+# 适用于全局共享的工具操作
+info = tool_proxy.tool_info()
+```
+
+#### Agent 模式
+```python
+# Agent 级别的 ToolProxy
+tool_proxy = store.for_agent("agent1").find_tool("get_weather")
+
+# 适用于 Agent 独立的工具操作
+info = tool_proxy.tool_info()
+```
+
+### 完整示例
+
+```python
+from mcpstore import MCPStore
+
+# 初始化
+store = MCPStore.setup_store()
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("weather")
+
+# 获取 ToolProxy
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+print("=== 工具信息 ===")
+info = tool_proxy.tool_info()
+print(f"名称: {info['name']}")
+print(f"描述: {info['description']}")
+
+print("\n=== 工具标签 ===")
+tags = tool_proxy.tool_tags()
+print(f"标签: {tags}")
+
+print("\n=== 工具模式 ===")
+schema = tool_proxy.tool_schema()
+print(f"输入模式: {schema}")
+
+print("\n=== 调用工具 ===")
+result = tool_proxy.call_tool({"query": "北京"})
+print(f"结果: {result.text_output}")
+
+print("\n=== 使用统计 ===")
+stats = tool_proxy.usage_stats()
+print(f"统计: {stats}")
+
+print("\n=== 调用历史 ===")
+history = tool_proxy.call_history(limit=5)
+print(f"历史记录数: {len(history)}")
+```
+
+### 相关文档
+
+- [find_tool()](find-tool.md) - 查找工具获取 ToolProxy
+- [tool_info()](../details/tool-info.md) - 工具详情方法
+- [call_tool()](../usage/call-tool.md) - 工具调用方法
+- [set_redirect()](../config/set-redirect.md) - 工具配置方法
+
+### 设计理念
+
+ToolProxy 的设计理念与 ServiceProxy 一致:
+
+- 封装性: 将工具相关的所有操作封装在一个对象中
+- 便捷性: 提供链式调用和简洁的 API
+- 一致性: 与 ServiceProxy 保持相同的设计模式
+- 隔离性: 支持 Store/Agent 双模式的工具管理
+
diff --git a/docs/docs/tools/tool-tags.md b/docs/docs/tools/tool-tags.md
new file mode 100644
index 00000000..1f493081
--- /dev/null
+++ b/docs/docs/tools/tool-tags.md
@@ -0,0 +1,71 @@
+## tool_tags - 工具标签
+
+
+获取工具的标签列表。
+
+### SDK
+
+同步:
+ - `tool_proxy.tool_tags() -> List[str]`
+
+### 参数
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| 无 | - | 该方法不需要参数。 |
+
+### 返回值
+
+- 类型:`List[str]`
+- 说明:工具标签名称列表。
+
+
+### 视角
+在通过 `find_tool()` 获取的 `ToolProxy` 上调用。支持 Store 与 Agent 视角。
+
+
+### 使用示例
+
+基本使用:
+```python
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store()
+
+store.for_store().add_service({
+ "mcpServers": {
+ "weather": {"url": "https://mcpstore.wiki/mcp"}
+ }
+})
+
+store.for_store().wait_service("weather")
+
+tool_proxy = store.for_store().find_tool("get_current_weather")
+
+tags = tool_proxy.tool_tags()
+print("工具标签:", tags)
+```
+
+
+### 你可能想找的方法
+
+| 场景/方法 | 同步方法 |
+|----------------|----------|
+| 获取工具信息 | `tool_proxy.tool_info()` |
+| 获取输入模式 | `tool_proxy.tool_schema()` |
+| 查找工具 | `store.for_store().find_tool(name)` |
+| 列出工具 | `store.for_store().list_tools()` |
+
+
+### 使用场景
+
+- 在 UI 中展示工具分类或筛选条件。
+- 基于标签做工具启用、排序或权限配置。
+- 生成文档索引或搜索提示。
+
+
+### 注意事项
+
+- 标签可能为空列表,请做好判空处理。
+- 标签来源于服务注册时的工具元数据,可能因服务实现而异。
+
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
new file mode 100644
index 00000000..756ae9d4
--- /dev/null
+++ b/docs/mkdocs.yml
@@ -0,0 +1,219 @@
+site_name: McpStore
+site_description: good for mcp
+site_author: whillhill
+site_url: https://mcpstore.wiki
+
+# Repository
+repo_name: whillhill/mcpstore
+repo_url: https://github.com/whillhill/mcpstore
+edit_uri: edit/main/mcpstore_docs/docs/
+
+# Copyright
+copyright: Copyright © 2025 whillhill
+
+# Configuration
+theme:
+ name: material
+ language: zh
+
+ # Logo and favicon
+ logo: assets/logo_w.svg
+ favicon: assets/favicon.ico
+
+ # 字体配置
+ font:
+ text: Noto Sans SC
+ code: JetBrains Mono
+
+ # Color palette
+ palette:
+ # Palette toggle for light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-7
+ name: 切换到深色模式
+
+ # Palette toggle for dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-4
+ name: 切换到浅色模式
+
+ # Features - 优化的左右布局配置
+ features:
+ - announce.dismiss
+ - content.action.edit
+ - content.action.view
+ - content.code.annotate
+ - content.code.copy
+ - content.tabs.link
+ - content.tooltips
+ - header.autohide
+ - navigation.footer
+ - navigation.indexes
+ - navigation.instant
+ - navigation.instant.prefetch
+ - navigation.instant.progress
+ - navigation.prune
+ - navigation.sections
+ # 不使用顶部标签页,保持左侧导航
+ - navigation.tabs
+ - navigation.tabs.sticky
+ - navigation.top
+ - navigation.tracking
+ - search.highlight
+ - search.share
+ - search.suggest
+ # 关键配置:右侧显示页面内目录
+ - toc.follow
+ # 不集成到左侧,保持独立的右侧目录
+ # - toc.integrate
+
+# Plugins
+plugins:
+ - search:
+ separator: '[\s\u200b\-_,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
+ lang:
+ - zh
+ - en
+ - minify:
+ minify_html: true
+ minify_css: true
+ minify_js: true
+ htmlmin_opts:
+ remove_comments: true
+ remove_empty_space: true
+ cache_safe: true
+
+# Customization
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/whillhill/mcpstore
+ name: GitHub 仓库
+ - icon: fontawesome/brands/python
+ link: https://pypi.org/project/mcpstore/
+ name: PyPI 包
+ - icon: fontawesome/solid/book
+ link: https://mcpstore.wiki
+ name: 在线文档
+
+extra_css:
+ - assets/extra.css
+
+# Extensions
+markdown_extensions:
+ - toc:
+ permalink: true
+ toc_depth: 3
+ - tables
+ - fenced_code
+ - abbr
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - pymdownx.arithmatex:
+ generic: true
+ - pymdownx.betterem:
+ smart_enable: all
+ - pymdownx.caret
+ - pymdownx.details
+ - pymdownx.emoji:
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.keys
+ - pymdownx.magiclink:
+ normalize_issue_symbols: true
+ repo_url_shorthand: true
+ user: mcpstore
+ repo: mcpstore
+ - pymdownx.mark
+ - pymdownx.smartsymbols
+ - pymdownx.snippets:
+ auto_append:
+ - includes/mkdocs.md
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.tabbed:
+ alternate_style: true
+ combine_header_slug: true
+ - pymdownx.tasklist:
+ custom_checkbox: true
+ - pymdownx.tilde
+
+# Navigation
+nav:
+ - 首页:
+ - 文档导览: index.md
+ - 快速开始: quickstart.md
+
+ - 理解 Store 与数据源:
+ - Store 角色与边界: store/overview.md
+ - Agent 与 Store 的关系: store/list-agents.md
+ - 数据源与服务代理: services/service-proxy.md
+ - 缓存/数据源架构: architecture/cache-architecture.md
+
+ - 构建与配置:
+ - Store 初始化流程: quickstart.md
+ - 配置项说明: api/cache-config.md
+ - 配置参考手册: api/cache-config-reference.md
+
+ - 服务治理:
+ - 服务概览: services/overview.md
+ - 添加服务: services/add-service.md
+ - Hub 服务管理: hub/services.md
+ - 服务列表与发现: services/list-services.md
+ - 状态与健康: services/check-health.md
+ - 配置读写: services/show-config.md
+ - 更新与补丁: services/update-service.md
+ - 重载与等待: services/restart-service.md
+ - 清理与删除: services/delete-service.md
+
+ - 三大原语:
+ - Prompt:
+ - 列出 Prompt: prompts/list-prompts.md
+ - 获取 Prompt: prompts/get-prompt.md
+ - Resource:
+ - 列出资源: resources/list-resources.md
+ - 读取资源: resources/read-resource.md
+ - Tool:
+ - 工具概览: tools/overview.md
+ - 列出工具: tools/list-tools.md
+ - 查找工具: tools/find-tool.md
+ - 调用工具: tools/call-tool.md
+ - 工具信息: tools/tool-info.md
+ - 路由策略: tools/tool-tags.md
+ - 代理与重定向: tools/tool-proxy.md
+
+ - 创建 Agent 与上下文:
+ - Agent 创建与接入: integrations/overview.md
+ - Agent 信息与连接详情: store/get-info.md
+ - 会话与上下文: session/sessions.md
+
+ - API 参考:
+ - API 总览: api/overview.md
+ - 认证与配置: api/auth.md
+ - 服务端点参考: api/reference.md
+ - 错误与健康检查: api/errors.md
+
+ - CLI 使用:
+ - CLI 命令: cli/commands.md
+
+ - 示例:
+ - 缓存配置示例: examples/cache-config-examples.md
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..b3b77469
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,14 @@
+# MkDocs 核心
+mkdocs>=1.5.0
+
+# Material 主题
+mkdocs-material>=9.4.0
+
+# 插件
+mkdocs-minify-plugin>=0.7.0
+pymdown-extensions>=10.0.0
+
+# 可选插件(根据需要启用)
+# mkdocs-pdf-export-plugin>=0.5.10
+# mkdocs-with-pdf>=0.9.3
+# mkdocs-mermaid2-plugin>=1.1.0
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 00000000..72770d20
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,141 @@
+# MCPStore 测试示例
+
+本目录包含 MCPStore 的完整测试示例,按功能模块组织。
+
+## 📁 目录结构
+
+```
+example/
+├── utils/ # 公共工具模块
+│ ├── __init__.py
+│ └── import_helper.py # 导入路径配置
+├── init/ # 初始化测试
+│ ├── test_store_init_basic.py
+│ ├── test_store_init_redis.py
+│ ├── test_agent_init_basic.py
+│ ├── test_mixed_init_comparison.py
+│ └── README.md
+├── service/ # 服务管理测试(待创建)
+├── tool/ # 工具管理测试(待创建)
+├── integration/ # 框架集成测试(待创建)
+├── database/ # 数据库测试(待创建)
+└── auth/ # 认证测试(待创建)
+```
+
+## 🎯 命名规则
+
+所有测试文件遵循统一的命名规则:
+
+```
+test_{上下文模式}_{功能板块}_{具体场景}.py
+```
+
+### 上下文模式
+- `store` - Store 级别(全局共享)
+- `agent` - Agent 级别(独立隔离)
+- `mixed` - 混合模式(对比测试)
+
+### 功能板块
+- `init` - 初始化
+- `service_*` - 服务管理(add/find/detail/wait/health/update/restart/delete/config)
+- `tool_*` - 工具管理(find/detail/use/config/stats)
+- `integration_*` - 框架集成(langchain/llamaindex/crewai等)
+- `database_*` - 数据库支持(redis等)
+- `auth` - 权限认证
+
+## 🚀 快速开始
+
+### 运行单个测试
+
+```bash
+# 运行 Store 基础初始化测试
+python example/init/test_store_init_basic.py
+```
+
+### 运行某个模块的所有测试
+
+```bash
+# Windows
+for %f in (example\init\test_*.py) do python %f
+
+# Linux/Mac
+for f in example/init/test_*.py; do python "$f"; done
+```
+
+## 💡 导入机制
+
+所有测试文件使用统一的导入配置:
+
+```python
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+```
+
+### 导入优先级
+1. **本地开发版本**: 优先使用 `src/mcpstore`(如果存在)
+2. **环境安装版本**: 如果本地不存在,使用 pip 安装的版本
+
+### 为什么这样设计?
+- ✅ 开发时可以直接测试本地代码
+- ✅ 不需要每次都重新安装包
+- ✅ 支持同时测试多个版本
+- ✅ 对环境友好,自动回退
+
+## 📝 测试设计原则
+
+1. **无 try-except 包裹核心测试**
+ - 核心测试代码不使用 try-except
+ - 让错误自然抛出,方便调试
+ - 可以清晰看到项目的错误反馈
+
+2. **简单直接**
+ - 不定义复杂函数
+ - 代码顺序执行
+ - 输出清晰易读
+
+3. **完整覆盖**
+ - 每个功能板块都有对应测试
+ - Store 和 Agent 模式都测试
+ - 覆盖常见使用场景
+
+4. **输出友好**
+ - ✅ 成功操作
+ - ⚠️ 警告提示
+ - ❌ 错误信息
+ - 💡 使用建议
+
+## 📚 模块说明
+
+### ✅ 已完成模块
+
+- **init/** - 初始化测试
+ - Store 基础初始化
+ - Store + Redis 初始化
+ - Agent 基础初始化
+ - Store vs Agent 对比
+
+### 🚧 计划中模块
+
+详见完整的测试文件规划文档。
+
+## 🔗 相关文档
+
+- [MCPStore 文档](../mcpstore_docs/docs/)
+- [快速上手](../mcpstore_docs/docs/getting-started/quickstart.md)
+- [服务管理](../mcpstore_docs/docs/services/overview.md)
+- [工具管理](../mcpstore_docs/docs/tools/overview.md)
+
+## 🤝 贡献
+
+欢迎提交新的测试用例!请遵循现有的命名规则和代码风格。
+
+---
+
+**开始测试吧!** 🚀
+
diff --git a/example/api/exp_run_service.py b/example/api/exp_run_service.py
new file mode 100644
index 00000000..5744f5fe
--- /dev/null
+++ b/example/api/exp_run_service.py
@@ -0,0 +1,20 @@
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+# prod_store = MCPStore.setup_store('./prod_workspace/mcp.json')
+prod_store = MCPStore.setup_store(debug = True,mcp_config_file=r'../test_workspaces/workspace1/mcp.json')
+# prod_store = MCPStore.setup_store(mcp_config_file=r'S:\BaiduSyncdisk\2025_6\mcpstore\test_workspaces\workspace1\mcp.json')
+# prod_store = MCPStore.setup_store(mcp_config_file=r'S:\BaiduSyncdisk\2025_6\mcpstore\test_workspaces\workspace1\mcp.json',debug = True)
+prod_store.start_api_server(
+ host='0.0.0.0',
+ port=18200,
+ show_startup_info=False,
+ # log_level='warning'
+)
+
diff --git a/example/basic/10_langchain_no_session_invoke.py b/example/basic/10_langchain_no_session_invoke.py
new file mode 100644
index 00000000..8c6956eb
--- /dev/null
+++ b/example/basic/10_langchain_no_session_invoke.py
@@ -0,0 +1,51 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+import os
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store(debug=False)
+
+
+demo_mcp = {
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp", "--isolated"
+ ]
+ }
+ }
+}
+
+store.for_store().add_service(demo_mcp)
+store.for_store().wait_service("playwright",timeout=30)
+
+tools = store.for_store().for_langchain().list_tools()
+
+print("loaded langchain tools:", len(tools))
+
+llm = ChatOpenAI(
+ temperature=0,
+ model=os.getenv("OPENAI_MODEL", "deepseek-chat"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+ openai_api_base=os.getenv("OPENAI_API_BASE", "https://api.deepseek.com"),
+)
+
+agent_graph = create_agent(
+model=llm,
+tools=tools,
+system_prompt="你是一个助手,回答的时候带上表情",
+)
+query = "使用工具,给我打开百度并搜索蓝色电风扇一步步来"
+print(f"\nQ: {query}")
+events = agent_graph.invoke({"messages": [{"role": "user", "content": query}]})
+print(events)
+
+
diff --git a/example/basic/10_langchain_no_session_stream.py b/example/basic/10_langchain_no_session_stream.py
new file mode 100644
index 00000000..7d02b48a
--- /dev/null
+++ b/example/basic/10_langchain_no_session_stream.py
@@ -0,0 +1,54 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+import os
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+
+from mcpstore import MCPStore
+
+
+store = MCPStore.setup_store(debug=True)
+
+
+demo_mcp = {
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": ["@playwright/mcp", "--isolated"]
+ }
+ }
+}
+
+store.for_store().add_service(demo_mcp)
+store.for_store().wait_service("playwright",timeout=30)
+
+
+tools = store.for_store().for_langchain().list_tools()
+
+print("loaded langchain tools:", len(tools))
+
+llm = ChatOpenAI(
+ temperature=0,
+ model=os.getenv("OPENAI_MODEL", "deepseek-chat"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+ openai_api_base=os.getenv("OPENAI_API_BASE", "https://api.deepseek.com"),
+)
+
+agent_graph = create_agent(
+model=llm,
+tools=tools,
+system_prompt="你是一个助手,回答的时候带上表情",
+)
+events = agent_graph.stream({"messages": [{"role": "user", "content": "打开百度,搜索白色电风扇"}]})
+for event in events:
+ event_type = list(event.keys())[0]
+ event_data = event[event_type]
+ print(f"\n[事件类型: {event_type}]")
+ print(event_data)
+
+
+
diff --git a/example/basic/10_langchain_remote_invoke.py b/example/basic/10_langchain_remote_invoke.py
new file mode 100644
index 00000000..452a149f
--- /dev/null
+++ b/example/basic/10_langchain_remote_invoke.py
@@ -0,0 +1,48 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+import os
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store(debug=False)
+
+
+demo_mcp ={
+ "mcpServers": {
+ "mcpstore_wiki": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+}
+
+store.for_store().add_service(demo_mcp)
+store.for_store().wait_service("mcpstore_wiki",timeout=30)
+
+tools = store.for_store().for_langchain().list_tools()
+
+print("loaded langchain tools:", len(tools))
+
+llm = ChatOpenAI(
+ temperature=0,
+ model=os.getenv("OPENAI_MODEL", "deepseek-chat"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+ openai_api_base=os.getenv("OPENAI_API_BASE", "https://api.deepseek.com"),
+)
+
+agent_graph = create_agent(
+model=llm,
+tools=tools,
+system_prompt="你是一个助手,回答的时候带上表情",
+)
+query = "mcpstore怎么添加服务?"
+print(f"\nQ: {query}")
+events = agent_graph.invoke({"messages": [{"role": "user", "content": query}]})
+print(events)
+
+
diff --git a/example/basic/10_langchain_session_invoke.py b/example/basic/10_langchain_session_invoke.py
new file mode 100644
index 00000000..7c16cd91
--- /dev/null
+++ b/example/basic/10_langchain_session_invoke.py
@@ -0,0 +1,55 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+import os
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store(debug=False)
+
+
+demo_mcp = {
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp", "--isolated"
+ ]
+ }
+ }
+}
+
+store.for_store().add_service(demo_mcp)
+store.for_store().wait_service("playwright",timeout=30)
+
+session1 = store.for_store().create_session("langchain_browser")
+session1.bind_service("playwright")
+
+with store.for_store().with_session(session1.session_id) as s:
+ tools = store.for_store().for_langchain().list_tools()
+
+ print("loaded langchain tools:", len(tools))
+
+ llm = ChatOpenAI(
+ temperature=0,
+ model=os.getenv("OPENAI_MODEL", "deepseek-chat"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+ openai_api_base=os.getenv("OPENAI_API_BASE", "https://api.deepseek.com"),
+ )
+
+ agent_graph = create_agent(
+ model=llm,
+ tools=tools,
+ system_prompt="你是一个助手,回答的时候带上表情",
+ )
+ query = "使用工具,给我打开百度并搜索蓝色电风扇一步步来"
+ print(f"\nQ: {query}")
+ events = agent_graph.invoke({"messages": [{"role": "user", "content": query}]})
+ print(events)
+
+
diff --git a/example/basic/10_langchain_session_stream.py b/example/basic/10_langchain_session_stream.py
new file mode 100644
index 00000000..0977ea00
--- /dev/null
+++ b/example/basic/10_langchain_session_stream.py
@@ -0,0 +1,58 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+import os
+from langchain.agents import create_agent
+from langchain_openai import ChatOpenAI
+
+from mcpstore import MCPStore
+
+store = MCPStore.setup_store(debug=False)
+
+
+demo_mcp = {
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp", "--isolated"
+ ]
+ }
+ }
+}
+
+store.for_store().add_service(demo_mcp)
+store.for_store().wait_service("playwright",timeout=30)
+
+session1 = store.for_store().create_session("langchain_browser")
+session1.bind_service("playwright")
+
+with store.for_store().with_session(session1.session_id) as s:
+ tools = store.for_store().for_langchain().list_tools()
+
+ print("loaded langchain tools:", len(tools))
+
+ llm = ChatOpenAI(
+ temperature=0,
+ model=os.getenv("OPENAI_MODEL", "deepseek-chat"),
+ openai_api_key=os.getenv("OPENAI_API_KEY", ""),
+ openai_api_base=os.getenv("OPENAI_API_BASE", "https://api.deepseek.com"),
+ )
+
+ agent_graph = create_agent(
+ model=llm,
+ tools=tools,
+ system_prompt="你是一个助手,回答的时候带上表情",
+ )
+ events = agent_graph.stream({"messages": [{"role": "user", "content": "使用工具,给我打开百度并搜索蓝色电风扇一步步来"}]})
+ for event in events:
+ event_type = list(event.keys())[0]
+ event_data = event[event_type]
+ print(f"\n[事件类型: {event_type}]")
+ print(event_data)
+
+
+
diff --git a/example/basic/exp_01_add_local_service_agent.py b/example/basic/exp_01_add_local_service_agent.py
new file mode 100644
index 00000000..287658be
--- /dev/null
+++ b/example/basic/exp_01_add_local_service_agent.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""
+基础示例:添加本地服务 (Agent 级别)
+功能:演示如何为 Agent 添加一个本地 MCP 服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加本地服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "howtocook": {
+ "command": "npx",
+ "args": ["-y", "howtocook-mcp"]
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("howtocook")
+
+# 打印Agent服务列表
+services = store.for_agent("demo_agent").list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_01_add_local_service_store.py b/example/basic/exp_01_add_local_service_store.py
new file mode 100644
index 00000000..4870eaa5
--- /dev/null
+++ b/example/basic/exp_01_add_local_service_store.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""
+基础示例:添加本地服务 (Store 级别)
+功能:演示如何添加一个本地 MCP 服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加本地服务
+store.for_store().add_service({
+ "mcpServers": {
+ "howtocook": {
+ "command": "npx",
+ "args": ["-y", "howtocook-mcp"]
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("howtocook")
+
+# 打印服务列表
+services = store.for_store().list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_02_add_remote_service_agent.py b/example/basic/exp_02_add_remote_service_agent.py
new file mode 100644
index 00000000..e82fc79d
--- /dev/null
+++ b/example/basic/exp_02_add_remote_service_agent.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:添加远程服务 (Agent 级别)
+功能:演示如何为 Agent 添加一个远程 HTTP MCP 服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加远程服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 打印Agent服务列表
+services = store.for_agent("demo_agent").list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_02_add_remote_service_store.py b/example/basic/exp_02_add_remote_service_store.py
new file mode 100644
index 00000000..cc510997
--- /dev/null
+++ b/example/basic/exp_02_add_remote_service_store.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""
+基础示例:添加远程服务 (Store 级别)
+功能:演示如何添加一个远程 HTTP MCP 服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+#导入mcpstore
+from mcpstore import MCPStore
+
+#初始化一个store
+store = MCPStore.setup_store(debug=False)
+
+# 添加一个远程服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 打印服务列表
+services = store.for_store().list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
+
diff --git a/example/basic/exp_03_list_services_agent.py b/example/basic/exp_03_list_services_agent.py
new file mode 100644
index 00000000..c4afba5d
--- /dev/null
+++ b/example/basic/exp_03_list_services_agent.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:列出服务 (Agent 级别)
+功能:演示如何列出 Agent 的所有服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加测试服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 打印Agent服务列表
+services = store.for_agent("demo_agent").list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_03_list_services_store.py b/example/basic/exp_03_list_services_store.py
new file mode 100644
index 00000000..a2f21e5c
--- /dev/null
+++ b/example/basic/exp_03_list_services_store.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:列出服务 (Store 级别)
+功能:演示如何列出所有服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加测试服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 打印服务列表
+services = store.for_store().list_services()
+print(services)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_04_list_tools_agent.py b/example/basic/exp_04_list_tools_agent.py
new file mode 100644
index 00000000..c00fd449
--- /dev/null
+++ b/example/basic/exp_04_list_tools_agent.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:列出工具 (Agent 级别)
+功能:演示如何列出 Agent 的所有可用工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 打印Agent工具列表
+tools = store.for_agent("demo_agent").list_tools()
+print(tools)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_04_list_tools_store.py b/example/basic/exp_04_list_tools_store.py
new file mode 100644
index 00000000..2a95ed7a
--- /dev/null
+++ b/example/basic/exp_04_list_tools_store.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:列出工具 (Store 级别)
+功能:演示如何列出所有可用工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 打印工具列表
+tools = store.for_store().list_tools()
+print(tools)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_05_call_local_tool_agent.py b/example/basic/exp_05_call_local_tool_agent.py
new file mode 100644
index 00000000..8238a2e1
--- /dev/null
+++ b/example/basic/exp_05_call_local_tool_agent.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""
+基础示例:调用本地工具 (Agent 级别)
+功能:演示如何调用 Agent 的本地 MCP 服务工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加本地服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "howtocook": {
+ "command": "npx",
+ "args": ["-y", "howtocook-mcp"]
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("howtocook")
+
+# 调用Agent工具
+result = store.for_agent("demo_agent").use_tool('getAllRecipes', {})
+print(result)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_05_call_local_tool_store.py b/example/basic/exp_05_call_local_tool_store.py
new file mode 100644
index 00000000..b619e32a
--- /dev/null
+++ b/example/basic/exp_05_call_local_tool_store.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""
+基础示例:调用本地工具 (Store 级别)
+功能:演示如何调用本地 MCP 服务的工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加本地服务
+store.for_store().add_service({
+ "mcpServers": {
+ "howtocook": {
+ "command": "npx",
+ "args": ["-y", "howtocook-mcp"]
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("howtocook")
+
+# 调用工具
+result = store.for_store().use_tool('getAllRecipes', {})
+print(result)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_06_call_remote_tool_agent.py b/example/basic/exp_06_call_remote_tool_agent.py
new file mode 100644
index 00000000..ed0549c5
--- /dev/null
+++ b/example/basic/exp_06_call_remote_tool_agent.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:调用远程工具 (Agent 级别)
+功能:演示如何调用 Agent 的远程 HTTP MCP 服务工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加远程服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 调用Agent远程工具
+result = store.for_agent("demo_agent").use_tool('get_current_weather', {"query": "北京"})
+print(result)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_06_call_remote_tool_store.py b/example/basic/exp_06_call_remote_tool_store.py
new file mode 100644
index 00000000..2c577c75
--- /dev/null
+++ b/example/basic/exp_06_call_remote_tool_store.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""
+基础示例:调用远程工具 (Store 级别)
+功能:演示如何调用远程 HTTP MCP 服务的工具
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加远程服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 调用远程工具
+result = store.for_store().use_tool('get_current_weather', {"query": "北京"})
+print(result)
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_07_reset_config_agent.py b/example/basic/exp_07_reset_config_agent.py
new file mode 100644
index 00000000..2be7b28a
--- /dev/null
+++ b/example/basic/exp_07_reset_config_agent.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""
+基础示例:重置配置 (Agent 级别)
+功能:演示如何重置配置,清除 Agent 的所有服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加测试服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 重置前查看Agent服务数
+services_before = store.for_agent("demo_agent").list_services()
+print(f"重置前Agent服务数: {len(services_before)}")
+
+# 重置配置(包括所有Agent的服务)
+store.for_store().reset_config()
+
+# 重置后查看Agent服务数
+services_after = store.for_agent("demo_agent").list_services()
+print(f"重置后Agent服务数: {len(services_after)}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_07_reset_config_store.py b/example/basic/exp_07_reset_config_store.py
new file mode 100644
index 00000000..1bcbe1b9
--- /dev/null
+++ b/example/basic/exp_07_reset_config_store.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""
+基础示例:重置配置 (Store 级别)
+功能:演示如何重置配置,清除所有服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加一些测试服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 重置前查看服务数
+services_before = store.for_store().list_services()
+print(f"重置前服务数: {len(services_before)}")
+
+# 重置配置
+store.for_store().reset_config()
+
+# 重置后查看服务数
+services_after = store.for_store().list_services()
+print(f"重置后服务数: {len(services_after)}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_08_delete_service_agent.py b/example/basic/exp_08_delete_service_agent.py
new file mode 100644
index 00000000..b01c7d1a
--- /dev/null
+++ b/example/basic/exp_08_delete_service_agent.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""
+基础示例:删除服务 (Agent 级别)
+功能:演示如何删除 Agent 的服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 删除前查看Agent服务数
+services_before = store.for_agent("demo_agent").list_services()
+print(f"删除前Agent服务数: {len(services_before)}")
+
+# 删除Agent服务
+store.for_agent("demo_agent").find_service("mcpstore").delete_service()
+
+# 删除后查看Agent服务数
+services_after = store.for_agent("demo_agent").list_services()
+print(f"删除后Agent服务数: {len(services_after)}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_08_delete_service_store.py b/example/basic/exp_08_delete_service_store.py
new file mode 100644
index 00000000..d5d9e374
--- /dev/null
+++ b/example/basic/exp_08_delete_service_store.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""
+基础示例:删除服务 (Store 级别)
+功能:演示如何删除服务
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 删除前查看服务数
+services_before = store.for_store().list_services()
+print(f"删除前服务数: {len(services_before)}")
+
+# 删除服务
+store.for_store().find_service("mcpstore").delete_service()
+
+# 删除后查看服务数
+services_after = store.for_store().list_services()
+print(f"删除后服务数: {len(services_after)}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_09_update_service_agent.py b/example/basic/exp_09_update_service_agent.py
new file mode 100644
index 00000000..4d007a8a
--- /dev/null
+++ b/example/basic/exp_09_update_service_agent.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""
+基础示例:更新服务 (Agent 级别)
+功能:演示如何更新 Agent 的服务配置
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 为Agent添加服务
+store.for_agent("demo_agent").add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_agent("demo_agent").wait_service("mcpstore")
+
+# 更新前查看配置
+service_info_before = store.for_agent("demo_agent").find_service("mcpstore").service_info()
+print(f"更新前URL: {service_info_before.url}")
+
+# 更新Agent服务配置
+store.for_agent("demo_agent").find_service("mcpstore").update_config({
+ "url": "https://mcp.context7.com/mcp"
+})
+
+# 更新后查看配置
+config = store.config.load_config()
+agent_service_name = "mcpstore_byagent_demo_agent"
+if agent_service_name in config.get("mcpServers", {}):
+ weather_config = config["mcpServers"][agent_service_name]
+ print(f"更新后URL: {weather_config.get('url')}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_09_update_service_store.py b/example/basic/exp_09_update_service_store.py
new file mode 100644
index 00000000..e4133bcf
--- /dev/null
+++ b/example/basic/exp_09_update_service_store.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""
+基础示例:更新服务 (Store 级别)
+功能:演示如何更新服务配置
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from mcpstore import MCPStore
+
+# 初始化store
+store = MCPStore.setup_store(debug=False)
+
+# 添加服务
+store.for_store().add_service({
+ "mcpServers": {
+ "mcpstore": {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+})
+
+# 等待服务就绪
+store.for_store().wait_service("mcpstore")
+
+# 更新前查看配置
+service_info_before = store.for_store().find_service("mcpstore").service_info()
+print(f"更新前URL: {service_info_before.url}")
+
+# 更新服务配置
+store.for_store().find_service("mcpstore").update_config({
+ "url": "https://mcp.context7.com/mcp"
+})
+
+# 更新后查看配置
+config = store.config.load_config()
+weather_config = config.get("mcpServers", {}).get("mcpstore", {})
+print(f"更新后URL: {weather_config.get('url')}")
+
+# 清空配置方便下次演示
+store.for_store().reset_config()
diff --git a/example/basic/exp_special_playwright_session.py b/example/basic/exp_special_playwright_session.py
new file mode 100644
index 00000000..e593e62f
--- /dev/null
+++ b/example/basic/exp_special_playwright_session.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+特殊示例:Playwright 浏览器会话持久化
+功能:演示浏览器状态在多次工具调用间的持久化
+"""
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.import_helper import setup_import_path
+setup_import_path()
+
+from utils.cleanup_helper import print_and_reset_config
+from mcpstore import MCPStore
+
+print("=" * 60)
+print("特殊示例:Playwright 浏览器会话持久化")
+print("=" * 60)
+
+# 初始化 Store(完整链式)
+print("\n✅ 初始化 Store")
+store = MCPStore.setup_store(debug=True)
+
+# 添加 Playwright 服务(完整链式)
+print("\n✅ 添加 Playwright 服务")
+store.for_store().add_service({
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": ["@playwright/mcp"]
+ }
+ }
+})
+store.for_store().wait_service("playwright", timeout=30)
+print(" Playwright 服务添加完成")
+
+# 第一次调用:导航到百度(完整链式)
+print("\n✅ 第一次调用:导航到百度")
+result1 = store.for_store().use_tool("playwright_browser_navigate", {"url": "https://www.baidu.com"})
+print(f" 导航完成,结果长度: {len(str(result1))}")
+if "baidu.com" in str(result1):
+ print(" ✅ 成功导航到百度")
+
+# 第二次调用:获取页面快照(完整链式)
+print("\n✅ 第二次调用:获取页面快照")
+result2 = store.for_store().use_tool("playwright_browser_snapshot", {"input": ""})
+print(f" 快照获取完成,结果长度: {len(str(result2))}")
+
+# 验证会话持久化
+if "baidu.com" in str(result2):
+ print(" ✅ 浏览器状态保持,仍在百度页面")
+ print(" 🎉 会话持久化成功!")
+elif "about:blank" in str(result2):
+ print(" ❌ 浏览器状态丢失,页面重置为空白页")
+else:
+ print(" ⚠️ 页面状态不明确")
+
+print("\n💡 会话持久化说明:")
+print(" - MCPStore 会自动维护浏览器会话")
+print(" - 多次工具调用共享同一个浏览器实例")
+print(" - 无需手动管理会话生命周期")
+
+print("\n" + "=" * 60)
+print("✅ 示例完成!")
+print("=" * 60)
+
+# 清理配置
+print_and_reset_config(store, "清理示例配置")
+
diff --git "a/example/quick_start/Agent\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py" "b/example/quick_start/Agent\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
new file mode 100644
index 00000000..448f4684
--- /dev/null
+++ "b/example/quick_start/Agent\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
@@ -0,0 +1,422 @@
+from example_utils import setup_example_import
+
+setup_example_import()
+from mcpstore import MCPStore
+
+
+# ============================================================
+# AgentProxy Usage Example - Complete Agent Management Demonstration
+#
+# KEY FEATURE: Unified AgentProxy Caching System
+# Both store.for_agent(agent_id) and find_agent(agent_id) return
+# IDENTICAL objects with perfect synchronization.
+#
+# Benefits:
+# - Object identity: proxy1 is proxy2 returns True
+# - State synchronization: Modifications affect both references
+# - Performance: Cached access reduces overhead
+# - Thread safety: Concurrent access is safe and consistent
+# ============================================================
+
+print("\n" + "=" * 60)
+print(" AgentProxy Usage Example")
+print("=" * 60)
+
+# ============================================================
+# IMPORTANT NOTE: Unified AgentProxy Caching System
+# ============================================================
+"""
+UNIFIED AGENTPROXY ACCESS:
+
+In this MCPStore implementation, both methods return IDENTICAL AgentProxy objects:
+
+• store.for_agent(agent_id)
+• store.for_store().find_agent(agent_id)
+
+KEY PROPERTIES:
+✅ Object Identity: Both return the same instance (proxy1 is proxy2 → True)
+✅ State Synchronization: Modifications through either reference affect both
+✅ Cache Consistency: Multiple calls return the same cached object
+✅ Thread Safety: Concurrent access is safe and consistent
+
+EXAMPLE:
+ proxy1 = store.for_agent("agent_id")
+ proxy2 = store.for_store().find_agent("agent_id")
+
+ assert proxy1 is proxy2 # ✅ True - Same object
+ proxy1.add_service(config) # Affects both references
+ assert len(proxy2.list_services()) > 0 # ✅ True - Changes reflected
+
+This unified design ensures data consistency and eliminates resource waste
+by maintaining a single AgentProxy instance per agent_id.
+"""
+
+print("\n" + "NOTE: Both for_agent() and find_agent() return IDENTICAL objects")
+print(" Modifications through either reference affect both equally")
+
+# ------------------------------------------------------------
+# Step 1: Initialize MCPStore
+# ------------------------------------------------------------
+print("\n[Step 1] Initialize MCPStore")
+store = MCPStore.setup_store(debug=True)
+print(" └─ ✓ MCPStore instance created successfully")
+
+# ------------------------------------------------------------
+# Step 2: Reset Configuration (Clean Environment)
+# ------------------------------------------------------------
+print("\n[Step 2] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ------------------------------------------------------------
+# Step 3: Show Initial Configuration (Empty State)
+# ------------------------------------------------------------
+print("\n[Step 3] Show Initial Configuration")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+print(f" ├─ Agents: {list(agents.keys()) if agents else []}")
+print(" └─ ✓ Initial configuration is empty")
+
+# ------------------------------------------------------------
+# Step 4: Add MCP Service to Store
+# ------------------------------------------------------------
+print("\n[Step 4] Add MCP Service to Store")
+service_name = "mcpstore"
+service_config = {
+ "mcpServers": {
+ service_name: {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+}
+store.for_store().add_service(service_config)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service added to store successfully")
+
+# ------------------------------------------------------------
+# Step 5: Wait for Service Ready
+# ------------------------------------------------------------
+print("\n[Step 5] Wait for Service Ready")
+store.for_store().wait_service(service_name)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service is ready")
+
+# ------------------------------------------------------------
+# Step 6: List Store Services
+# ------------------------------------------------------------
+print("\n[Step 6] List Store Services")
+services = store.for_store().list_services()
+print(f" ├─ Total Services: {len(services)}")
+for idx, service in enumerate(services, 1):
+ svc_name = service.get('name', 'N/A')
+ svc_status = str(service.get('status', 'N/A')).split('.')[-1].replace("'", "")
+ svc_url = service.get('url', 'N/A')
+ svc_tools = service.get('tool_count', 0)
+ print(f" ├─ [{idx}] {svc_name}")
+ print(f" │ ├─ Status: {svc_status}")
+ print(f" │ ├─ URL: {svc_url}")
+ print(f" │ └─ Tools: {svc_tools}")
+print(" └─ ✓ Store service list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 7: Create AgentProxy Object
+# ------------------------------------------------------------
+print("\n[Step 7] Create AgentProxy Object")
+agent_id = "demo_agent"
+agent_proxy = store.for_agent(agent_id)
+print(f" ├─ Agent ID: {agent_id}")
+print(f" ├─ Agent Proxy: {agent_proxy}")
+print(" └─ ✓ AgentProxy created successfully")
+
+# ------------------------------------------------------------
+# Step 8: Unified AgentProxy Access Demonstration
+# ------------------------------------------------------------
+print("\n[Step 8] Unified AgentProxy Access Demonstration")
+store_proxy = store.for_store()
+agent_proxy_alt = store_proxy.find_agent(agent_id)
+
+print(f" ├─ Primary Method: store.for_agent('{agent_id}')")
+print(f" │ └─ Object ID: {id(agent_proxy)}")
+print(f" ├─ Alternative Method: store_proxy.find_agent('{agent_id}')")
+print(f" │ └─ Object ID: {id(agent_proxy_alt)}")
+print(f" ├─ Object Identity Test (is): {agent_proxy is agent_proxy_alt}")
+print(f" ├─ Value Equality Test (==): {agent_proxy == agent_proxy_alt}")
+print(f" └─ BOTH METHODS RETURN IDENTICAL OBJECTS")
+
+# Demonstrate modification synchronization
+print(f"\n Testing Modification Synchronization:")
+initial_services_count = len(agent_proxy.list_services())
+print(f" ├─ Initial services via primary proxy: {initial_services_count}")
+
+# Access store services through the store context
+store_services = store.for_store().list_services()
+print(f" ├─ Store services count: {len(store_services)}")
+print(f" └─ Both proxies share identical state and capabilities")
+
+# ------------------------------------------------------------
+# Step 9: Basic Properties Test
+# ------------------------------------------------------------
+print("\n[Step 9] Basic Properties Test")
+print(f" ├─ Agent ID: {agent_proxy.get_id()}")
+print(" └─ ✓ Basic properties retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 10: Agent Information Test
+# ------------------------------------------------------------
+print("\n[Step 10] Agent Information Test")
+agent_info = agent_proxy.get_info()
+print(f" ├─ Info Type: {type(agent_info).__name__}")
+print(f" ├─ Agent ID: {agent_info.get('agent_id', 'N/A')}")
+print(f" ├─ Name: {agent_info.get('name', 'N/A')}")
+print(f" ├─ Description: {agent_info.get('description', 'N/A')}")
+print(f" ├─ Created At: {agent_info.get('created_at', 'N/A')}")
+print(f" ├─ Last Active: {agent_info.get('last_active', 'N/A')}")
+print(f" └─ ✓ Agent information retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 11: Agent Statistics Test
+# ------------------------------------------------------------
+print("\n[Step 11] Agent Statistics Test")
+agent_stats = agent_proxy.get_stats()
+print(f" ├─ Stats Type: {type(agent_stats).__name__}")
+print(f" ├─ Service Count: {agent_stats.get('service_count', 0)}")
+print(f" ├─ Tool Count: {agent_stats.get('tool_count', 0)}")
+print(f" ├─ Healthy Services: {agent_stats.get('healthy_services', 0)}")
+print(f" ├─ Unhealthy Services: {agent_stats.get('unhealthy_services', 0)}")
+print(f" ├─ Total Tool Executions: {agent_stats.get('total_tool_executions', 0)}")
+print(f" ├─ Is Active: {agent_stats.get('is_active', False)}")
+print(f" ├─ Last Activity: {agent_stats.get('last_activity', 'N/A')}")
+print(f" └─ ✓ Agent statistics retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 12: Unified Modification Synchronization Test
+# ------------------------------------------------------------
+print("\n[Step 12] Unified Modification Synchronization Test")
+
+# Get initial state from both proxies
+initial_services_for = agent_proxy.list_services()
+initial_services_alt = agent_proxy_alt.list_services()
+
+print(f" Initial State:")
+print(f" ├─ Services via primary proxy: {len(initial_services_for)}")
+print(f" ├─ Services via alternative proxy: {len(initial_services_alt)}")
+print(f" └─ States identical: {initial_services_for == initial_services_alt}")
+
+# Add a service using the primary proxy
+print(f"\n Adding service using PRIMARY proxy:")
+try:
+ # Add the existing store service to agent
+ agent_proxy.add_service_to_agent(service_name)
+ print(f" ├─ Service '{service_name}' added via primary proxy")
+except Exception as e:
+ print(f" ├─ Service add operation: {str(e)}")
+
+# Check if both proxies see the change
+updated_services_for = agent_proxy.list_services()
+updated_services_alt = agent_proxy_alt.list_services()
+
+print(f"\n Updated State:")
+print(f" ├─ Services via primary proxy: {len(updated_services_for)}")
+print(f" ├─ Services via alternative proxy: {len(updated_services_alt)}")
+print(f" ├─ States synchronized: {updated_services_for == updated_services_alt}")
+print(f" └─ MODIFICATIONS PERFECTLY SYNCHRONIZED")
+
+# Verify object identity remains consistent
+print(f"\n Object Identity Verification:")
+print(f" ├─ Primary proxy unchanged: {id(agent_proxy)}")
+print(f" ├─ Alternative proxy unchanged: {id(agent_proxy_alt)}")
+print(f" ├─ Still same object: {agent_proxy is agent_proxy_alt}")
+print(f" └─ UNIFIED BEHAVIOR CONFIRMED")
+
+# ------------------------------------------------------------
+# Step 13: Add Service to Agent
+# ------------------------------------------------------------
+print("\n[Step 13] Add Service to Agent")
+agent_service_name = "mcpstore_agent"
+agent_service_config = {
+ "mcpServers": {
+ agent_service_name: {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+}
+add_result = agent_proxy.add_service(agent_service_config)
+print(f" ├─ Service Name: {agent_service_name}")
+print(f" ├─ Agent ID: {agent_id}")
+print(f" ├─ Add Result: {add_result}")
+print(" └─ ✓ Service added to agent successfully")
+
+# ------------------------------------------------------------
+# Step 14: List Agent Services
+# ------------------------------------------------------------
+print("\n[Step 14] List Agent Services")
+agent_services = agent_proxy.list_services()
+print(f" ├─ Agent ID: {agent_id}")
+print(f" ├─ Total Agent Services: {len(agent_services)}")
+for idx, service in enumerate(agent_services, 1):
+ svc_name = service.get('name', 'N/A')
+ svc_status = str(service.get('status', 'N/A')).split('.')[-1].replace("'", "")
+ svc_url = service.get('url', 'N/A')
+ svc_tools = service.get('tool_count', 0)
+ print(f" ├─ [{idx}] {svc_name}")
+ print(f" │ ├─ Status: {svc_status}")
+ print(f" │ ├─ URL: {svc_url}")
+ print(f" │ └─ Tools: {svc_tools}")
+print(" └─ ✓ Agent service list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 14: Get ServiceProxy from Agent
+# ------------------------------------------------------------
+print("\n[Step 14] Get ServiceProxy from Agent")
+if agent_services:
+ first_service_name = agent_services[0].get('name', 'N/A')
+ service_proxy = agent_proxy.find_service(first_service_name)
+ print(f" ├─ Service Name: {first_service_name}")
+ print(f" ├─ Service Proxy: {service_proxy}")
+ print(f" ├─ Service Context Type: {service_proxy.context_type}")
+ print(" └─ ✓ ServiceProxy obtained from agent successfully")
+else:
+ print(" └─ ⚠ No services available to proxy")
+
+# ------------------------------------------------------------
+# Step 15: List Agent Tools
+# ------------------------------------------------------------
+print("\n[Step 15] List Agent Tools")
+agent_tools = agent_proxy.list_tools()
+print(f" ├─ Agent ID: {agent_id}")
+print(f" ├─ Total Agent Tools: {len(agent_tools)}")
+for idx, tool in enumerate(agent_tools, 1):
+ tool_name = tool.get('name', 'N/A')
+ tool_desc = tool.get('description', 'N/A')
+ tool_service = tool.get('service_name', 'N/A')
+ print(f" ├─ [{idx}] {tool_name}")
+ print(f" │ ├─ Service: {tool_service}")
+ print(f" │ └─ Description: {tool_desc}")
+print(" └─ ✓ Agent tool list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 16: Get ToolProxy from Agent
+# ------------------------------------------------------------
+print("\n[Step 16] Get ToolProxy from Agent")
+if agent_tools:
+ first_tool_name = agent_tools[0].get('name', 'mcpstore_get_mcpstore_docs')
+ tool_proxy = agent_proxy.find_tool(first_tool_name)
+ print(f" ├─ Tool Name: {first_tool_name}")
+ print(f" ├─ Tool Proxy: {tool_proxy}")
+ print(f" ├─ Tool Context Type: {tool_proxy.context_type}")
+ print(" └─ ✓ ToolProxy obtained from agent successfully")
+else:
+ print(" └─ ⚠ No tools available to proxy")
+
+# ------------------------------------------------------------
+# Step 17: Call Tool via Agent
+# ------------------------------------------------------------
+print("\n[Step 17] Call Tool via Agent")
+if agent_tools:
+ tool_name = "mcpstore_get_mcpstore_docs"
+ tool_params = {}
+ try:
+ tool_result = agent_proxy.call_tool(tool_name, tool_params)
+ print(f" ├─ Tool: {tool_name}")
+ print(f" ├─ Parameters: {tool_params}")
+ print(f" ├─ Result Type: {type(tool_result).__name__}")
+
+ if isinstance(tool_result, dict):
+ is_error = tool_result.get('is_error', False)
+ content = tool_result.get('content', [])
+ print(f" ├─ Is Error: {is_error}")
+ print(f" ├─ Content Items: {len(content)}")
+ for idx, item in enumerate(content, 1):
+ item_type = item.get('type', 'N/A')
+ item_text = item.get('text', 'N/A')
+ print(f" ├─ [{idx}] Type: {item_type}")
+ print(f" │ └─ Text: {item_text}")
+
+ print(" └─ ✓ Tool called successfully via agent")
+ except Exception as e:
+ print(f" ├─ Tool: {tool_name}")
+ print(f" ├─ Parameters: {tool_params}")
+ print(f" ├─ Error: {str(e)}")
+ print(" └─ ⚠ Tool call failed via agent")
+else:
+ print(" └─ ⚠ No tools available to call")
+
+# ------------------------------------------------------------
+# Step 18: Check Agent Services Health
+# ------------------------------------------------------------
+print("\n[Step 18] Check Agent Services Health")
+try:
+ health_status = agent_proxy.check_services()
+ print(f" ├─ Agent ID: {agent_id}")
+ print(f" ├─ Health Check Type: {type(health_status).__name__}")
+ if isinstance(health_status, dict):
+ health_keys = list(health_status.keys())
+ print(f" ├─ Health Keys: {health_keys}")
+ print(f" ├─ Healthy Services: {health_status.get('healthy_services', 'N/A')}")
+ print(f" ├─ Unhealthy Services: {health_status.get('unhealthy_services', 'N/A')}")
+ print(" └─ ✓ Agent services health checked successfully")
+except Exception as e:
+ print(f" ├─ Error: {str(e)}")
+ print(" └─ ⚠ Health check failed")
+
+# ------------------------------------------------------------
+# Step 19: Name Mapping Test
+# ------------------------------------------------------------
+print("\n[Step 19] Name Mapping Test")
+test_service_name = "test_service"
+# Test local to global mapping
+global_name = agent_proxy.map_global(test_service_name)
+print(f" ├─ Local Name: {test_service_name}")
+print(f" ├─ Global Name: {global_name}")
+
+# Test global to local mapping
+local_name = agent_proxy.map_local(global_name)
+print(f" ├─ Global to Local: {local_name}")
+print(" └─ ✓ Name mapping completed successfully")
+
+# ------------------------------------------------------------
+# Step 20: Framework Adapter Test
+# ------------------------------------------------------------
+print("\n[Step 20] Framework Adapter Test")
+langchain_adapter = agent_proxy.for_langchain()
+print(f" ├─ LangChain Adapter: {type(langchain_adapter).__name__}")
+print(f" ├─ Adapter Created: True")
+print(" └─ ✓ Framework adapter created successfully")
+
+# ------------------------------------------------------------
+# Step 21: Show Configuration Before Reset
+# ------------------------------------------------------------
+print("\n[Step 21] Show Configuration Before Reset")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+for agent_name, agent_data in agents.items():
+ services = agent_data.get('services', {})
+ print(f" ├─ Agent: {agent_name}")
+ for svc_name, svc_data in services.items():
+ svc_url = svc_data.get('config', {}).get('url', 'N/A')
+ svc_client = svc_data.get('client_id', 'N/A')
+ print(f" │ ├─ Service: {svc_name}")
+ print(f" │ │ ├─ URL: {svc_url}")
+ print(f" │ │ └─ Client ID: {svc_client}")
+print(" └─ ✓ Configuration retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 22: Reset Configuration (Final Cleanup)
+# ------------------------------------------------------------
+print("\n[Step 22] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ============================================================
+print("\n" + "=" * 60)
+print(" AgentProxy Usage Completed")
+print("=" * 60)
+print()
diff --git "a/example/quick_start/\345\267\245\345\205\267\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py" "b/example/quick_start/\345\267\245\345\205\267\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
new file mode 100644
index 00000000..d2588cb0
--- /dev/null
+++ "b/example/quick_start/\345\267\245\345\205\267\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
@@ -0,0 +1,362 @@
+from example_utils import setup_example_import
+
+setup_example_import()
+from mcpstore import MCPStore
+
+
+# ============================================================
+# ToolProxy Usage Example - Complete Method Demonstration
+#
+# RELATED FEATURE: Unified AgentProxy Caching System
+# This ToolProxy example works seamlessly with the unified AgentProxy system.
+# When accessing AgentProxies, both methods return IDENTICAL objects:
+#
+# - store.for_agent(agent_id)
+# - store.for_store().find_agent(agent_id)
+#
+# Benefits for ToolProxy usage:
+# - Consistent agent state across all access patterns
+# - Tool operations are synchronized regardless of agent access method
+# - Performance optimized through agent-level caching
+# - Thread-safe concurrent tool execution
+# ============================================================
+
+print("\n" + "=" * 60)
+print(" ToolProxy Usage Example")
+print("=" * 60)
+
+print("\n" + "NOTE: ToolProxy works with unified AgentProxy caching system")
+print(" Consistent behavior across all agent access methods")
+
+# ------------------------------------------------------------
+# Step 1: Initialize MCPStore
+# ------------------------------------------------------------
+print("\n[Step 1] Initialize MCPStore")
+store = MCPStore.setup_store(debug=True)
+print(" └─ ✓ MCPStore instance created successfully")
+
+# ------------------------------------------------------------
+# Step 2: Reset Configuration (Clean Environment)
+# ------------------------------------------------------------
+print("\n[Step 2] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ------------------------------------------------------------
+# Step 3: Show Initial Configuration (Empty State)
+# ------------------------------------------------------------
+print("\n[Step 3] Show Initial Configuration")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+print(f" ├─ Agents: {list(agents.keys()) if agents else []}")
+print(" └─ ✓ Initial configuration is empty")
+
+# ------------------------------------------------------------
+# Step 4: Add MCP Service
+# ------------------------------------------------------------
+print("\n[Step 4] Add MCP Service")
+service_name = "mcpstore"
+service_config = {
+ "mcpServers": {
+ service_name: {
+ "url": "https://www.mcpstore.wiki/mcp"
+ # "url": "https://mcp.context7.com/mcp"
+ }
+ }
+}
+store.for_store().add_service(service_config)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service added successfully")
+
+# ------------------------------------------------------------
+# Step 5: Wait for Service Ready
+# ------------------------------------------------------------
+print("\n[Step 5] Wait for Service Ready")
+store.for_store().wait_service(service_name)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service is ready")
+
+# ------------------------------------------------------------
+# Step 6: List All Services
+# ------------------------------------------------------------
+print("\n[Step 6] List All Services")
+services = store.for_store().list_services()
+print(f" ├─ Total Services: {len(services)}")
+for idx, service in enumerate(services, 1):
+ svc_name = service.get('name', 'N/A')
+ svc_status = str(service.get('status', 'N/A')).split('.')[-1].replace("'", "")
+ svc_url = service.get('url', 'N/A')
+ svc_tools = service.get('tool_count', 0)
+ print(f" ├─ [{idx}] {svc_name}")
+ print(f" │ ├─ Status: {svc_status}")
+ print(f" │ ├─ URL: {svc_url}")
+ print(f" │ └─ Tools: {svc_tools}")
+print(" └─ ✓ Service list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 7: List All Tools
+# ------------------------------------------------------------
+print("\n[Step 7] List All Tools")
+tools = store.for_store().list_tools()
+print(f" ├─ Total Tools: {len(tools)}")
+for idx, tool in enumerate(tools, 1):
+ tool_name = tool.get('name', 'N/A')
+ tool_desc = tool.get('description', 'N/A')
+ tool_service = tool.get('service_name', 'N/A')
+ print(f" ├─ [{idx}] {tool_name}")
+ print(f" │ ├─ Service: {tool_service}")
+ print(f" │ └─ Description: {tool_desc}")
+print(" └─ ✓ Tool list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 8: Get ServiceProxy Object
+# ------------------------------------------------------------
+print("\n[Step 8] Get ServiceProxy Object")
+service_proxy = store.for_store().find_service(service_name)
+print(f" ├─ Service Proxy: {service_proxy}")
+print(" └─ ✓ ServiceProxy obtained successfully")
+
+# ------------------------------------------------------------
+# Step 9: Get ToolProxy Object (Multiple Ways)
+# ------------------------------------------------------------
+print("\n[Step 9] Get ToolProxy Object")
+
+# Use specific tool name for testing
+selected_tool_name = "mcpstore_get_mcpstore_docs"
+tool_proxy_from_store = store.for_store().find_tool(selected_tool_name)
+print(f" ├─ ToolProxy (Store Context): {tool_proxy_from_store}")
+
+# Also get via service context
+tool_proxy_from_service = service_proxy.find_tool(selected_tool_name)
+print(f" ├─ ToolProxy (Service Context): {tool_proxy_from_service}")
+
+# Get tool info for reference
+tool_info_for_ref = tool_proxy_from_store.tool_info()
+selected_tool_desc = tool_info_for_ref.get('description', 'N/A')
+
+print(f" ├─ Selected Tool: {selected_tool_name}")
+print(" └─ ✓ ToolProxy objects obtained successfully")
+
+# ------------------------------------------------------------
+# Step 10: Basic Properties Test
+# ------------------------------------------------------------
+print("\n[Step 10] Basic Properties Test")
+tool_proxy = tool_proxy_from_store
+print(f" ├─ Tool Name: {tool_proxy.tool_name}")
+print(f" ├─ Tool Name (Property): {tool_proxy.name}")
+print(f" ├─ Context Type: {tool_proxy.context_type}")
+print(f" ├─ Scope: {tool_proxy.scope}")
+print(f" ├─ Service Name: {tool_proxy.service_name}")
+print(" └─ ✓ Basic properties retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 11: Tool Information Test
+# ------------------------------------------------------------
+print("\n[Step 11] Tool Information Test")
+tool_info = tool_proxy.tool_info()
+print(f" ├─ Tool Info Type: {type(tool_info).__name__}")
+print(f" ├─ Name: {tool_info.get('name', 'N/A')}")
+print(f" ├─ Description: {tool_info.get('description', 'N/A')}")
+print(f" ├─ Service Name: {tool_info.get('service_name', 'N/A')}")
+print(f" ├─ Client ID: {tool_info.get('client_id', 'N/A')}")
+print(f" ├─ Scope: {tool_info.get('scope', 'N/A')}")
+print(f" ├─ Tags Count: {len(tool_info.get('tags', []))}")
+print(f" ├─ Meta Keys: {list(tool_info.get('meta', {}).keys())}")
+print(" └─ ✓ Tool information retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 12: Tool Schema Test
+# ------------------------------------------------------------
+print("\n[Step 12] Tool Schema Test")
+tool_schema = tool_proxy.tool_schema()
+print(f" ├─ Has Schema: {tool_schema is not None}")
+print(f" ├─ Schema Type: {type(tool_schema).__name__ if tool_schema else 'None'}")
+if tool_schema:
+ schema_keys = list(tool_schema.keys()) if isinstance(tool_schema, dict) else 'N/A'
+ print(f" ├─ Schema Keys: {schema_keys}")
+print(f" ├─ Has Schema (Property): {tool_proxy.has_schema}")
+print(" └─ ✓ Tool schema retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 13: Tool Tags and Meta Test
+# ------------------------------------------------------------
+print("\n[Step 13] Tool Tags and Meta Test")
+tool_tags = tool_proxy.tool_tags()
+tool_meta = tool_proxy.tool_meta()
+print(f" ├─ Tags: {tool_tags}")
+print(f" ├─ Tags Count: {len(tool_tags)}")
+print(f" ├─ Meta Keys: {list(tool_meta.keys())}")
+print(f" ├─ FastMCP Meta: {tool_meta.get('_fastmcp', {})}")
+print(" └─ ✓ Tags and meta retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 14: Tool Availability Test
+# ------------------------------------------------------------
+print("\n[Step 14] Tool Availability Test")
+print(f" ├─ Is Available: {tool_proxy.is_available}")
+print(f" ├─ Description: {tool_proxy.description}")
+print(" └─ ✓ Tool availability checked successfully")
+
+# ------------------------------------------------------------
+# Step 15: Call Tool Test
+# ------------------------------------------------------------
+print("\n[Step 15] Call Tool Test")
+tool_name = selected_tool_name
+tool_params = {}
+tool_result = store.for_store().call_tool(tool_name, tool_params)
+print(f" ├─ Tool: {tool_name}")
+print(f" ├─ Parameters: {tool_params}")
+if isinstance(tool_result, dict):
+ is_error = tool_result.get('is_error', False)
+ content = tool_result.get('content', [])
+ print(f" ├─ Is Error: {is_error}")
+ print(f" ├─ Content Items: {len(content)}")
+ for idx, item in enumerate(content, 1):
+ item_type = item.get('type', 'N/A')
+ item_text = item.get('text', 'N/A')
+ print(f" ├─ [{idx}] Type: {item_type}")
+ print(f" │ └─ Text: {item_text}")
+print(" └─ ✓ Tool called successfully")
+
+# ------------------------------------------------------------
+# Step 15.1: ToolProxy Call Tool Test
+# ------------------------------------------------------------
+print("\n[Step 15.1] ToolProxy Call Tool Test")
+try:
+ tool_proxy_result = tool_proxy.call_tool(tool_params, return_extracted=False)
+ print(f" ├─ ToolProxy: {tool_proxy}")
+ print(f" ├─ Parameters: {tool_params}")
+ print(f" ├─ Result Type: {type(tool_proxy_result).__name__}")
+
+ # Check if result is ToolCallResult
+ if hasattr(tool_proxy_result, 'content'):
+ print(f" ├─ Is ToolCallResult: True")
+ print(f" ├─ Is Error: {tool_proxy_result.is_error}")
+ content_items = tool_proxy_result.content if tool_proxy_result.content else []
+ print(f" ├─ Content Items: {len(content_items)}")
+
+ # Try to get text output safely
+ try:
+ text_output = tool_proxy_result.text_output if hasattr(tool_proxy_result, 'text_output') else 'N/A'
+ if text_output != 'N/A' and len(text_output) > 100:
+ text_output = text_output[:100] + "..."
+ print(f" ├─ Text Output: {text_output}")
+ except:
+ print(f" ├─ Text Output: Not available")
+
+ # Try to get called_at safely
+ try:
+ called_at = tool_proxy_result.called_at if hasattr(tool_proxy_result, 'called_at') else 'N/A'
+ print(f" ├─ Called At: {called_at}")
+ except:
+ print(f" ├─ Called At: Not available")
+
+ else:
+ print(f" ├─ Is ToolCallResult: False")
+ print(f" ├─ Result: {str(tool_proxy_result)[:100]}...")
+
+ print(" └─ ✓ ToolProxy call completed successfully")
+except Exception as e:
+ print(f" ├─ ToolProxy: {tool_proxy}")
+ print(f" ├─ Parameters: {tool_params}")
+ print(f" ├─ Error: {str(e)}")
+ print(" └─ ⚠ ToolProxy call failed")
+
+# ------------------------------------------------------------
+# Step 16: Usage Statistics Test
+# ------------------------------------------------------------
+print("\n[Step 16] Usage Statistics Test")
+usage_stats = tool_proxy.usage_stats()
+print(f" ├─ Tool Name: {usage_stats.get('tool_name', 'N/A')}")
+print(f" ├─ Total Calls: {usage_stats.get('total_calls', 0)}")
+print(f" ├─ Recent Calls: {usage_stats.get('recent_calls', 0)}")
+print(f" ├─ Success Rate: {usage_stats.get('success_rate', 0.0)}")
+print(f" ├─ Average Duration: {usage_stats.get('average_duration', 0.0)}")
+if 'note' in usage_stats:
+ print(f" ├─ Note: {usage_stats['note']}")
+if 'error' in usage_stats:
+ print(f" ├─ Error: {usage_stats['error']}")
+print(" └─ ✓ Usage statistics retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 17: Call History Test
+# ------------------------------------------------------------
+print("\n[Step 17] Call History Test")
+call_history = tool_proxy.call_history(limit=5)
+print(f" ├─ History Records: {len(call_history)}")
+for idx, record in enumerate(call_history, 1):
+ record_time = record.get('timestamp', 'N/A')
+ record_success = not record.get('is_error', True)
+ print(f" ├─ [{idx}] Time: {record_time}, Success: {record_success}")
+print(" └─ ✓ Call history retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 18: Redirect Configuration Test
+# ------------------------------------------------------------
+print("\n[Step 18] Redirect Configuration Test")
+original_tool = tool_proxy
+redirected_tool = tool_proxy.set_redirect(True)
+print(f" ├─ Original Tool: {original_tool}")
+print(f" ├─ Redirected Tool: {redirected_tool}")
+print(f" ├─ Same Object: {original_tool is redirected_tool}")
+print(" └─ ✓ Redirect configuration applied successfully")
+
+# ------------------------------------------------------------
+# Step 19: Test Call with Validation
+# ------------------------------------------------------------
+print("\n[Step 19] Test Call with Validation")
+try:
+ test_result = tool_proxy.test_call({})
+ print(f" ├─ Test Call: Successful")
+ print(f" ├─ Result Type: {type(test_result).__name__}")
+ print(" └─ ✓ Test call completed successfully")
+except Exception as e:
+ print(f" ├─ Test Call: Failed")
+ print(f" ├─ Error: {str(e)}")
+ print(" └─ ⚠ Test call failed")
+
+# ------------------------------------------------------------
+# Step 20: String Representation Test
+# ------------------------------------------------------------
+print("\n[Step 20] String Representation Test")
+print(f" ├─ String Representation: {str(tool_proxy)}")
+print(f" ├─ Repr: {repr(tool_proxy)}")
+print(" └─ ✓ String representation completed successfully")
+
+# ------------------------------------------------------------
+# Step 21: Show Configuration Before Reset
+# ------------------------------------------------------------
+print("\n[Step 21] Show Configuration Before Reset")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+for agent_name, agent_data in agents.items():
+ services = agent_data.get('services', {})
+ print(f" ├─ Agent: {agent_name}")
+ for svc_name, svc_data in services.items():
+ svc_url = svc_data.get('config', {}).get('url', 'N/A')
+ svc_client = svc_data.get('client_id', 'N/A')
+ print(f" │ ├─ Service: {svc_name}")
+ print(f" │ │ ├─ URL: {svc_url}")
+ print(f" │ │ └─ Client ID: {svc_client}")
+print(" └─ ✓ Configuration retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 22: Reset Configuration (Final Cleanup)
+# ------------------------------------------------------------
+print("\n[Step 22] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ============================================================
+print("\n" + "=" * 60)
+print(" ToolProxy Usage Completed")
+print("=" * 60)
+print()
\ No newline at end of file
diff --git "a/example/quick_start/\346\234\215\345\212\241\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py" "b/example/quick_start/\346\234\215\345\212\241\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
new file mode 100644
index 00000000..048f5417
--- /dev/null
+++ "b/example/quick_start/\346\234\215\345\212\241\345\257\271\350\261\241\345\217\212\344\275\277\347\224\250.py"
@@ -0,0 +1,226 @@
+from example_utils import setup_example_import
+
+setup_example_import()
+from mcpstore import MCPStore
+
+
+# ============================================================
+# ServiceProxy Usage Example - Complete Method Demonstration
+# ============================================================
+
+print("\n" + "=" * 60)
+print(" ServiceProxy Usage Example")
+print("=" * 60)
+
+# ------------------------------------------------------------
+# Step 1: Initialize MCPStore
+# ------------------------------------------------------------
+print("\n[Step 1] Initialize MCPStore")
+store = MCPStore.setup_store(debug=False)
+print(" └─ ✓ MCPStore instance created successfully")
+
+# ------------------------------------------------------------
+# Step 2: Reset Configuration (Clean Environment)
+# ------------------------------------------------------------
+print("\n[Step 2] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ------------------------------------------------------------
+# Step 3: Show Initial Configuration (Empty State)
+# ------------------------------------------------------------
+print("\n[Step 3] Show Initial Configuration")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+print(f" ├─ Agents: {list(agents.keys()) if agents else []}")
+print(" └─ ✓ Initial configuration is empty")
+
+# ------------------------------------------------------------
+# Step 4: Add MCP Service
+# ------------------------------------------------------------
+print("\n[Step 4] Add MCP Service")
+service_name = "mcpstore"
+service_config = {
+ "mcpServers": {
+ service_name: {
+ "url": "https://www.mcpstore.wiki/mcp"
+ # "url": "https://mcp.context7.com/mcp"
+ }
+ }
+}
+store.for_store().add_service(service_config)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service added successfully")
+
+# ------------------------------------------------------------
+# Step 5: Wait for Service Ready
+# ------------------------------------------------------------
+print("\n[Step 5] Wait for Service Ready")
+store.for_store().wait_service(service_name)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service is ready")
+
+# ------------------------------------------------------------
+# Step 6: Get ServiceProxy Object
+# ------------------------------------------------------------
+print("\n[Step 6] Get ServiceProxy Object")
+service_proxy = store.for_store().find_service(service_name)
+print(f" ├─ Proxy Object: {service_proxy}")
+print(" └─ ✓ ServiceProxy obtained successfully")
+
+# ------------------------------------------------------------
+# Step 7: Basic Properties
+# ------------------------------------------------------------
+print("\n[Step 7] Basic Properties")
+print(f" ├─ Service Name: {service_proxy.name}")
+print(f" ├─ Service Name (Alt): {service_proxy.service_name}")
+print(f" ├─ Context Type: {service_proxy.context_type}")
+print(f" ├─ Tools Count: {service_proxy.tools_count}")
+print(f" ├─ Is Connected: {service_proxy.is_connected}")
+print(" └─ ✓ Basic properties retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 8: Service Information
+# ------------------------------------------------------------
+print("\n[Step 8] Service Information")
+service_info = service_proxy.service_info()
+print(f" ├─ Service Info Type: {type(service_info).__name__}")
+if hasattr(service_info, 'name'):
+ print(f" ├─ Name: {service_info.name}")
+ print(f" ├─ URL: {getattr(service_info, 'url', 'N/A')}")
+ print(f" ├─ Status: {getattr(service_info, 'status', 'N/A')}")
+print(" └─ ✓ Service information retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 9: Service Status
+# ------------------------------------------------------------
+print("\n[Step 9] Service Status")
+service_status = service_proxy.service_status()
+print(f" ├─ Status Type: {type(service_status).__name__}")
+if isinstance(service_status, dict):
+ for key, value in list(service_status.items())[:5]:
+ print(f" ├─ {key}: {value}")
+print(" └─ ✓ Service status retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 10: Health Check
+# ------------------------------------------------------------
+print("\n[Step 10] Health Check")
+is_healthy = service_proxy.is_healthy()
+print(f" ├─ Is Healthy: {is_healthy}")
+health_check = service_proxy.check_health()
+print(f" ├─ Service Name: {health_check.get('service_name', 'N/A')}")
+print(f" ├─ Status: {health_check.get('status', 'N/A')}")
+print(f" ├─ Healthy: {health_check.get('healthy', False)}")
+print(f" ├─ Response Time: {health_check.get('response_time', 'N/A')}")
+print(" └─ ✓ Health check completed successfully")
+
+# ------------------------------------------------------------
+# Step 11: Health Details
+# ------------------------------------------------------------
+print("\n[Step 11] Health Details")
+health_details = service_proxy.health_details()
+print(f" ├─ Service Name: {health_details.get('service_name', 'N/A')}")
+print(f" ├─ Status: {health_details.get('status', 'N/A')}")
+print(f" ├─ Healthy: {health_details.get('healthy', False)}")
+print(f" ├─ Response Time: {health_details.get('response_time', 'N/A')}")
+print(f" ├─ Error Message: {health_details.get('error_message', 'None')}")
+print(" └─ ✓ Health details retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 12: List Tools
+# ------------------------------------------------------------
+print("\n[Step 12] List Tools")
+tools = service_proxy.list_tools()
+print(f" ├─ Total Tools: {len(tools)}")
+for idx, tool in enumerate(tools, 1):
+ tool_name = tool.name if hasattr(tool, 'name') else 'N/A'
+ tool_desc = tool.description if hasattr(tool, 'description') else 'N/A'
+ print(f" ├─ [{idx}] {tool_name}")
+ print(f" │ └─ Description: {tool_desc}")
+print(" └─ ✓ Tools list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 13: Tools Statistics
+# ------------------------------------------------------------
+print("\n[Step 13] Tools Statistics")
+tools_stats = service_proxy.tools_stats()
+metadata = tools_stats.get('metadata', {})
+print(f" ├─ Total Tools: {metadata.get('total_tools', 0)}")
+print(f" ├─ Services Count: {metadata.get('services_count', 0)}")
+tools_by_service = metadata.get('tools_by_service', {})
+for svc, count in tools_by_service.items():
+ print(f" ├─ {svc}: {count} tools")
+print(" └─ ✓ Tools statistics retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 14: Find Specific Tool
+# ------------------------------------------------------------
+print("\n[Step 14] Find Specific Tool")
+if len(tools) > 0:
+ first_tool_name = tools[0].name if hasattr(tools[0], 'name') else None
+ if first_tool_name:
+ tool_proxy = service_proxy.find_tool(first_tool_name)
+ print(f" ├─ Tool Proxy: {tool_proxy}")
+ print(f" ├─ Tool Name: {first_tool_name}")
+ print(" └─ ✓ Tool proxy obtained successfully")
+ else:
+ print(" └─ ⚠ No tool name available")
+else:
+ print(" └─ ⚠ No tools available to find")
+
+# ------------------------------------------------------------
+# Step 15: Patch Service Configuration
+# ------------------------------------------------------------
+print("\n[Step 15] Patch Service Configuration")
+patch_updates = {"custom_field": "example_value"}
+patch_result = service_proxy.patch_config(patch_updates)
+print(f" ├─ Patch Updates: {patch_updates}")
+print(f" ├─ Patch Result: {patch_result}")
+print(" └─ ✓ Configuration patched successfully")
+
+# ------------------------------------------------------------
+# Step 16: Refresh Service Content
+# ------------------------------------------------------------
+print("\n[Step 16] Refresh Service Content")
+refresh_result = service_proxy.refresh_content()
+print(f" ├─ Refresh Result: {refresh_result}")
+print(" └─ ✓ Service content refreshed successfully")
+
+# ------------------------------------------------------------
+# Step 17: Show Configuration Before Reset
+# ------------------------------------------------------------
+print("\n[Step 17] Show Configuration Before Reset")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+for agent_name, agent_data in agents.items():
+ services = agent_data.get('services', {})
+ print(f" ├─ Agent: {agent_name}")
+ for svc_name, svc_data in services.items():
+ svc_url = svc_data.get('config', {}).get('url', 'N/A')
+ svc_client = svc_data.get('client_id', 'N/A')
+ print(f" │ ├─ Service: {svc_name}")
+ print(f" │ │ ├─ URL: {svc_url}")
+ print(f" │ │ └─ Client ID: {svc_client}")
+print(" └─ ✓ Configuration retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 18: Reset Configuration (Final Cleanup)
+# ------------------------------------------------------------
+print("\n[Step 18] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ============================================================
+print("\n" + "=" * 60)
+print(" ServiceProxy Usage Completed")
+print("=" * 60)
+print()
diff --git "a/example/quick_start/\346\240\207\345\207\206\351\223\276\350\267\257.py" "b/example/quick_start/\346\240\207\345\207\206\351\223\276\350\267\257.py"
new file mode 100644
index 00000000..1ab7f9fd
--- /dev/null
+++ "b/example/quick_start/\346\240\207\345\207\206\351\223\276\350\267\257.py"
@@ -0,0 +1,153 @@
+from example_utils import setup_example_import
+
+setup_example_import()
+from mcpstore import MCPStore
+
+
+# ============================================================
+# Standard Workflow Example - MCPStore Complete Operations
+# ============================================================
+
+print("\n" + "=" * 60)
+print(" MCPStore Standard Workflow Example")
+print("=" * 60)
+
+# ------------------------------------------------------------
+# Step 1: Initialize MCPStore
+# ------------------------------------------------------------
+print("\n[Step 1] Initialize MCPStore")
+store = MCPStore.setup_store(debug=True)
+print(" └─ ✓ MCPStore instance created successfully")
+
+# ------------------------------------------------------------
+# Step 2: Reset Configuration (Clean Environment)
+# ------------------------------------------------------------
+print("\n[Step 2] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ------------------------------------------------------------
+# Step 3: Show Initial Configuration (Empty State)
+# ------------------------------------------------------------
+print("\n[Step 3] Show Initial Configuration")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+print(f" ├─ Agents: {list(agents.keys()) if agents else []}")
+print(" └─ ✓ Initial configuration is empty")
+
+# ------------------------------------------------------------
+# Step 4: Add MCP Service
+# ------------------------------------------------------------
+print("\n[Step 4] Add MCP Service")
+agent_name = "demo_agent"
+service_name = "mcpstore"
+service_config = {
+ "mcpServers": {
+ service_name: {
+ "url": "https://www.mcpstore.wiki/mcp"
+ }
+ }
+}
+store.for_store().add_service(service_config)
+print(f" ├─ Service Name: {service_name}")
+print(f" ├─ Agent Name: {agent_name}")
+print(" └─ ✓ Service added successfully")
+
+# ------------------------------------------------------------
+# Step 5: Wait for Service Ready
+# ------------------------------------------------------------
+print("\n[Step 5] Wait for Service Ready")
+store.for_store().wait_service(service_name)
+print(f" ├─ Service Name: {service_name}")
+print(" └─ ✓ Service is ready")
+
+# ------------------------------------------------------------
+# Step 6: List All Services
+# ------------------------------------------------------------
+print("\n[Step 6] List All Services")
+services = store.for_store().list_services()
+print(f" ├─ Total Services: {len(services)}")
+# for idx, service in enumerate(services, 1):
+# svc_name = service.name
+# svc_status = str(service.get('status', 'N/A')).split('.')[-1].replace("'", "")
+# svc_url = service.get('url', 'N/A')
+# svc_tools = service.get('tool_count', 0)
+# print(f" ├─ [{idx}] {svc_name}")
+# print(f" │ ├─ Status: {svc_status}")
+# print(f" │ ├─ URL: {svc_url}")
+# print(f" │ └─ Tools: {svc_tools}")
+# print(" └─ ✓ Service list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 7: List All Tools
+# ------------------------------------------------------------
+print("\n[Step 7] List All Tools")
+tools = store.for_store().list_tools()
+print(f" ├─ Total Tools: {len(tools)}")
+for idx, tool in enumerate(tools, 1):
+ tool_name = tool.get('name', 'N/A')
+ tool_desc = tool.get('description', 'N/A')
+ tool_service = tool.get('service_name', 'N/A')
+ print(f" ├─ [{idx}] {tool_name}")
+ print(f" │ ├─ Service: {tool_service}")
+ print(f" │ └─ Description: {tool_desc}")
+print(" └─ ✓ Tool list retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 8: Call Tool
+# ------------------------------------------------------------
+print("\n[Step 8] Call Tool")
+tool_name = "mcpstore_get_mcpstore_docs"
+tool_params = {}
+tool_result = store.for_store().call_tool(tool_name, tool_params)
+print(f" ├─ Tool: {tool_name}")
+print(f" ├─ Parameters: {tool_params}")
+if isinstance(tool_result, dict):
+ is_error = tool_result.get('is_error', False)
+ content = tool_result.get('content', [])
+ print(f" ├─ Is Error: {is_error}")
+ print(f" ├─ Content Items: {len(content)}")
+ for idx, item in enumerate(content, 1):
+ item_type = item.get('type', 'N/A')
+ item_text = item.get('text', 'N/A')
+ print(f" ├─ [{idx}] Type: {item_type}")
+ print(f" │ └─ Text: {item_text}")
+print(" └─ ✓ Tool called successfully")
+
+# ------------------------------------------------------------
+# Step 9: Show Configuration Before Reset (With Services)
+# ------------------------------------------------------------
+print("\n[Step 9] Show Configuration Before Reset")
+config = store.for_store().show_config()
+summary = config.get('summary', {})
+agents = config.get('agents', {})
+print(f" ├─ Total Agents: {summary.get('total_agents', 0)}")
+print(f" ├─ Total Services: {summary.get('total_services', 0)}")
+print(f" ├─ Total Clients: {summary.get('total_clients', 0)}")
+for agent_name, agent_data in agents.items():
+ services = agent_data.get('services', {})
+ print(f" ├─ Agent: {agent_name}")
+ for svc_name, svc_data in services.items():
+ svc_url = svc_data.get('config', {}).get('url', 'N/A')
+ svc_client = svc_data.get('client_id', 'N/A')
+ print(f" │ ├─ Service: {svc_name}")
+ print(f" │ │ ├─ URL: {svc_url}")
+ print(f" │ │ └─ Client ID: {svc_client}")
+print(" └─ ✓ Configuration retrieved successfully")
+
+# ------------------------------------------------------------
+# Step 10: Reset Configuration (Final Cleanup)
+# ------------------------------------------------------------
+print("\n[Step 10] Reset Configuration")
+store.for_store().reset_config()
+print(" └─ ✓ Configuration reset successfully")
+
+# ============================================================
+print("\n" + "=" * 60)
+print(" Standard Workflow Completed")
+print("=" * 60)
+print()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..15abf60c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,112 @@
+[build-system]
+requires = ["setuptools>=42", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "mcpstore"
+version = "1.5.13"
+description = "A composable, ready-to-use MCP toolkit for agents and rapid integration."
+readme = "README.md"
+requires-python = ">=3.10"
+
+dependencies = [
+ "fastapi>=0.115.12",
+ "fastmcp>=2.7.1",
+ "httpx>=0.28.1",
+ "pydantic>=2.11.5",
+ "uvicorn>=0.30.0",
+ "typer>=0.9.0",
+ "py-key-value-aio>=0.1.0",
+ "toml>=0.10.2",
+ "watchdog>=3.0.0",
+]
+
+authors = [
+ {name = "ooooofish", email = "ooooofish@126.com"}
+]
+license = "MIT"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Operating System :: OS Independent",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/whillhill/mcpstore"
+"Bug Tracker" = "https://github.com/whillhill/mcpstore/issues"
+
+[project.scripts]
+mcpstore = "mcpstore.cli.main:main"
+
+[[tool.uv.index]]
+url = "https://mirrors.aliyun.com/pypi/simple"
+default = true
+
+[dependency-groups]
+dev = [
+ "py-key-value-aio>=0.2.8",
+]
+
+[project.optional-dependencies]
+
+dev = [
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.21.0",
+ "pytest-benchmark>=4.0.0",
+ "pytest-cov>=4.0.0",
+ "hypothesis>=6.0.0",
+]
+
+redis = [
+ "redis[hiredis]>=5.0.0",
+]
+
+
+langchain = [
+ "langchain>=0.1.0",
+ "langchain-core>=0.1.0",
+ "langchain-openai>=0.1.0",
+]
+
+
+llamaindex = [
+ "llama-index>=0.10.0"
+]
+
+autogen = [
+ "autogen>=0.2.0"
+]
+
+
+semantic-kernel = [
+ "semantic-kernel>=0.5.0"
+]
+
+all = [
+ "redis[hiredis]>=5.0.0",
+ "langchain>=0.1.0",
+ "langchain-core>=0.1.0",
+ "langchain-openai>=0.1.0",
+ "llama-index>=0.10.0",
+ "autogen>=0.2.0",
+ "semantic-kernel>=0.5.0",
+]
+
+
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+where = ["src"]
+exclude = ["tests*", "*test*", "web*", "mcpservice*"]
+
+[tool.setuptools.package-data]
+mcpstore = ["data/*.json", "data/**/*.json"]
+
+
+[tool.uv]
+index-url = "https://mirrors.aliyun.com/pypi/simple"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index b60019a3..00000000
--- a/setup.py
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env python
-"""
-setup.py for mcpstore
-"""
-from setuptools import setup, find_packages
-
-if __name__ == "__main__":
- setup(
- name="mcpstore",
- package_dir={"": "src"},
- packages=find_packages(where="src"),
- include_package_data=True,
- install_requires=[
- "fastapi",
- "fastmcp",
- "httpx"
- ],
- author="ooooofish",
- author_email="ooooofish@126.com",
- description="A composable, ready-to-use MCP toolkit for agents and rapid integration.",
- url="https://github.com/whillhill/mcpstore",
- license="MIT",
- )
diff --git a/src/mcpstore/__init__.py b/src/mcpstore/__init__.py
index e074d2a4..10d51a82 100644
--- a/src/mcpstore/__init__.py
+++ b/src/mcpstore/__init__.py
@@ -1,9 +1,139 @@
-"""
-MCPStore - 智能体工具服务商店
-提供简单易用的MCP工具管理和调用功能
-"""
-
-from mcpstore.core.store import MCPStore
-
-__version__ = "0.1.0"
-__all__ = ["MCPStore"]
+"""
+MCPStore - Model Context Protocol Service Management SDK
+A composable, ready-to-use MCP toolkit for AI Agents and rapid integration.
+"""
+
+__version__ = "1.5.13"
+
+
+# ===== Lazy loading implementation =====
+def __getattr__(name: str):
+ """Lazy-load public objects on first access to reduce import overhead."""
+
+ # Core classes
+ if name in ("LoggingConfig", "MCPStore"):
+ from mcpstore.config.config import LoggingConfig
+ from mcpstore.core.store import MCPStore
+
+ globals().update({
+ "LoggingConfig": LoggingConfig,
+ "MCPStore": MCPStore,
+ })
+ return globals()[name]
+
+ # Cache config classes
+ if name in ("MemoryConfig", "RedisConfig"):
+ from mcpstore.config import MemoryConfig, RedisConfig
+
+ globals().update({
+ "MemoryConfig": MemoryConfig,
+ "RedisConfig": RedisConfig,
+ })
+ return globals()[name]
+
+ # Core model classes
+ if name in ("ServiceInfo", "ServiceConnectionState", "ToolInfo", "ToolExecutionRequest"):
+ from mcpstore.core.models.service import ServiceInfo, ServiceConnectionState
+ from mcpstore.core.models.tool import ToolInfo, ToolExecutionRequest
+
+ globals().update({
+ "ServiceInfo": ServiceInfo,
+ "ServiceConnectionState": ServiceConnectionState,
+ "ToolInfo": ToolInfo,
+ "ToolExecutionRequest": ToolExecutionRequest,
+ })
+ return globals()[name]
+
+ if name in ("APIResponse", "ErrorDetail", "ResponseMeta", "Pagination", "ResponseBuilder"):
+ from mcpstore.core.models.response import APIResponse, ErrorDetail, ResponseMeta, Pagination
+ from mcpstore.core.models.response_builder import ResponseBuilder
+
+ globals().update({
+ "APIResponse": APIResponse,
+ "ErrorDetail": ErrorDetail,
+ "ResponseMeta": ResponseMeta,
+ "Pagination": Pagination,
+ "ResponseBuilder": ResponseBuilder,
+ })
+ return globals()[name]
+
+ if name == "ErrorCode":
+ from mcpstore.core.models.error_codes import ErrorCode
+
+ globals()["ErrorCode"] = ErrorCode
+ return ErrorCode
+
+ # Core exception classes
+ if name in ("MCPStoreException", "ServiceNotFoundException", "ToolExecutionError"):
+ from mcpstore.core.exceptions import (
+ MCPStoreException,
+ ServiceNotFoundException,
+ ToolExecutionError,
+ )
+
+ globals().update({
+ "MCPStoreException": MCPStoreException,
+ "ServiceNotFoundException": ServiceNotFoundException,
+ "ToolExecutionError": ToolExecutionError,
+ })
+ return globals()[name]
+
+ # Adapter classes (lazy import, fall back to None if adapter is not installed)
+ adapters_mapping = {
+ "LangChainAdapter": "langchain_adapter",
+ "OpenAIAdapter": "openai_adapter",
+ "AutoGenAdapter": "autogen_adapter",
+ "LlamaIndexAdapter": "llamaindex_adapter",
+ "CrewAIAdapter": "crewai_adapter",
+ "SemanticKernelAdapter": "semantic_kernel_adapter",
+ }
+
+ if name in adapters_mapping:
+ module_name = adapters_mapping[name]
+ try:
+ module = __import__(f"mcpstore.adapters.{module_name}", fromlist=[name])
+ adapter_class = getattr(module, name)
+ except ImportError:
+ adapter_class = None
+
+ globals()[name] = adapter_class
+ return adapter_class
+
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
+
+
+# ===== Public Exports (API surface) =====
+__all__ = [
+ # Core Classes
+ "MCPStore",
+ "LoggingConfig",
+
+ # Cache Config
+ "MemoryConfig",
+ "RedisConfig",
+
+ # Model Classes
+ "ServiceInfo",
+ "ServiceConnectionState",
+ "ToolInfo",
+ "ToolExecutionRequest",
+ "APIResponse",
+ "ResponseBuilder",
+ "ErrorDetail",
+ "ResponseMeta",
+ "Pagination",
+ "ErrorCode",
+
+ # Exception Classes
+ "MCPStoreException",
+ "ServiceNotFoundException",
+ "ToolExecutionError",
+
+ # Adapters
+ "LangChainAdapter",
+ "OpenAIAdapter",
+ "AutoGenAdapter",
+ "LlamaIndexAdapter",
+ "CrewAIAdapter",
+ "SemanticKernelAdapter",
+]
diff --git a/src/mcpstore/adapters/__init__.py b/src/mcpstore/adapters/__init__.py
new file mode 100644
index 00000000..65fcda54
--- /dev/null
+++ b/src/mcpstore/adapters/__init__.py
@@ -0,0 +1,24 @@
+"""
+Adapters module - Unified export of all adapters
+
+Provides adapters for various AI frameworks, facilitating integration of MCPStore
+into different AI Agent frameworks.
+"""
+
+from .autogen_adapter import AutoGenAdapter
+from .crewai_adapter import CrewAIAdapter
+# ===== Direct export of all adapters =====
+from .langchain_adapter import LangChainAdapter
+from .llamaindex_adapter import LlamaIndexAdapter
+from .openai_adapter import OpenAIAdapter
+from .semantic_kernel_adapter import SemanticKernelAdapter
+
+# ===== Public exports =====
+__all__ = [
+ "LangChainAdapter",
+ "OpenAIAdapter",
+ "AutoGenAdapter",
+ "LlamaIndexAdapter",
+ "CrewAIAdapter",
+ "SemanticKernelAdapter",
+]
diff --git a/src/mcpstore/adapters/autogen_adapter.py b/src/mcpstore/adapters/autogen_adapter.py
new file mode 100644
index 00000000..63bdb539
--- /dev/null
+++ b/src/mcpstore/adapters/autogen_adapter.py
@@ -0,0 +1,36 @@
+# src/mcpstore/adapters/autogen_adapter.py
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING, Callable, Any
+
+from .common import create_args_schema, build_sync_executor, attach_signature_from_schema
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+
+
+
+
+
+class AutoGenAdapter:
+ """
+ Adapter that produces plain Python functions suitable for AutoGen tool registration.
+ """
+ def __init__(self, context: 'MCPStoreContext'):
+ self._context = context
+
+ def list_tools(self) -> List[Callable[..., Any]]:
+ return self._context._sync_helper.run_async(self.list_tools_async())
+
+ async def list_tools_async(self) -> List[Callable[..., Any]]:
+ tools: List[Callable[..., Any]] = []
+ mcp_tools: List['ToolInfo'] = await self._context.list_tools_async()
+ for t in mcp_tools:
+ args_schema = create_args_schema(t)
+ fn = build_sync_executor(self._context, t.name, args_schema)
+ attach_signature_from_schema(fn, args_schema)
+ tools.append(fn)
+ return tools
+
diff --git a/src/mcpstore/adapters/common.py b/src/mcpstore/adapters/common.py
new file mode 100644
index 00000000..a6fd2e2a
--- /dev/null
+++ b/src/mcpstore/adapters/common.py
@@ -0,0 +1,322 @@
+# src/mcpstore/adapters/common.py
+from __future__ import annotations
+
+import inspect
+import json
+import keyword
+import re
+import warnings
+from typing import TYPE_CHECKING, Callable, Any, Type, List, Dict, Optional
+
+from pydantic import BaseModel, create_model, Field, ConfigDict
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+
+class ToolCallView(BaseModel):
+ """标准化 FastMCP CallToolResult 的辅助视图。"""
+
+ text: str = ""
+ artifacts: List[Dict[str, Any]] = Field(default_factory=list)
+ structured: Any = None
+ data: Any = None
+ is_error: bool = False
+ error_message: Optional[str] = None
+ raw: Any = None
+
+
+def _extract_text_blocks(contents: list) -> List[str]:
+ blocks: List[str] = []
+ for block in contents or []:
+ text = getattr(block, "text", None)
+ if isinstance(text, str):
+ blocks.append(text)
+ return blocks
+
+
+def _extract_artifacts(contents: list) -> List[Dict[str, Any]]:
+ artifacts: List[Dict[str, Any]] = []
+ for block in contents or []:
+ if hasattr(block, "text"):
+ continue
+ artifact = {"type": getattr(block, "type", block.__class__.__name__.lower())}
+ for attr in ("uri", "mime", "mime_type", "name", "filename", "size", "bytes", "width", "height"):
+ if hasattr(block, attr):
+ value = getattr(block, attr)
+ if value is not None:
+ artifact[attr] = value
+ artifacts.append(artifact)
+ return artifacts
+
+
+def call_tool_response_helper(result: Any) -> ToolCallView:
+ """
+ 将 FastMCP CallToolResult 统一转换为 ToolCallView,供适配器复用。
+ """
+ contents = getattr(result, "content", []) or []
+ text_blocks = _extract_text_blocks(contents)
+ artifacts = _extract_artifacts(contents)
+ text_output = "\n".join(text_blocks).strip()
+
+ structured = getattr(result, "structured_content", None)
+ data = getattr(result, "data", None)
+ if not text_output and data is not None:
+ text_output = str(data)
+
+ is_error = bool(getattr(result, "is_error", False) or getattr(result, "isError", False))
+ error_message = getattr(result, "error", None)
+ if is_error and not error_message:
+ error_message = text_output or "Tool execution failed"
+
+ return ToolCallView(
+ text=text_output,
+ artifacts=artifacts,
+ structured=structured,
+ data=data,
+ is_error=is_error,
+ error_message=error_message,
+ raw=result,
+ )
+
+
+def enhance_description(tool_info: 'ToolInfo') -> str:
+ base_description = tool_info.description or ""
+ schema_properties = tool_info.inputSchema.get("properties", {})
+ if not schema_properties:
+ return base_description
+ param_lines = []
+ for name, info in schema_properties.items():
+ param_type = info.get("type", "string")
+ param_desc = info.get("description", "")
+ line = f"- {name} ({param_type}): {param_desc}"
+ # If this is an array of objects, append nested shape hints
+ try:
+ if (param_type == "array" or (isinstance(param_type, list) and "array" in param_type)) and isinstance(info.get("items"), dict):
+ items = info["items"]
+ if items.get("type") == "object" and "properties" in items:
+ nested = []
+ for nkey, nprop in items["properties"].items():
+ ntype = nprop.get("type", "string")
+ ndesc = nprop.get("description", "")
+ nested.append(f" - {name}[].{nkey} ({ntype}) {ndesc}")
+ if nested:
+ line += "\n" + "\n".join(nested)
+ except Exception:
+ pass
+ param_lines.append(line)
+ return base_description + ("\n\nParameter descriptions:\n" + "\n".join(param_lines))
+
+
+def create_args_schema(tool_info: 'ToolInfo') -> Type[BaseModel]:
+ props = tool_info.inputSchema.get("properties", {})
+ required = tool_info.inputSchema.get("required", [])
+ type_mapping = {
+ "string": str, "number": float, "integer": int,
+ "boolean": bool, "array": list, "object": dict
+ }
+
+ # Build reserved names set (avoid BaseModel attributes like 'schema')
+ reserved_names = set(dir(BaseModel)) | {
+ "schema", "model_json_schema", "model_dump", "dict", "json",
+ "copy", "parse_obj", "parse_raw", "construct", "validate",
+ "schema_json", "__fields__", "__root__", "Config", "model_config",
+ }
+
+ def sanitize_name(original: str) -> str:
+ """
+ Convert any parameter name to a valid Python identifier.
+ - Replace non-alphanumeric/underscore characters with underscores
+ - Add prefix if starts with digit
+ - Add suffix if Python keyword or reserved name
+ """
+ # 1. Replace all invalid characters with underscores
+ safe = re.sub(r'[^a-zA-Z0-9_]', '_', original)
+
+ # 2. If starts with digit, add prefix
+ if safe and safe[0].isdigit():
+ safe = f"param_{safe}"
+
+ # 3. If Python keyword or reserved name, add suffix
+ if keyword.iskeyword(safe) or safe in reserved_names or safe.startswith("_"):
+ safe = f"{safe}_"
+
+ # 4. Ensure not empty and is valid identifier
+ if not safe or not safe.isidentifier():
+ safe = "param_"
+
+ return safe
+
+ fields: dict[str, tuple[type, Any]] = {}
+ for original_name, prop in props.items():
+ field_type = type_mapping.get(prop.get("type", "string"), str)
+
+ # Detect JSON Schema nullability/Optional
+ def _is_nullable(p: dict) -> bool:
+ try:
+ if p.get("nullable") is True:
+ return True
+ t = p.get("type")
+ if isinstance(t, list) and "null" in t:
+ return True
+ any_of = p.get("anyOf") or []
+ if isinstance(any_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in any_of):
+ return True
+ one_of = p.get("oneOf") or []
+ if isinstance(one_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in one_of):
+ return True
+ except Exception:
+ pass
+ return False
+
+ is_nullable = _is_nullable(prop)
+ is_required = original_name in required
+
+ # Handle default values: make non-required fields truly optional
+ default_value = prop.get("default", ...)
+ if not is_required and default_value == ...:
+ # Use None as a sentinel default so Pydantic treats field as optional
+ # Combined with exclude_unset=True, unset optionals won't be sent
+ default_value = None
+
+ # Apply Optional typing if nullable
+ try:
+ if is_nullable and field_type is not Any:
+ from typing import Optional as _Optional
+ field_type = _Optional[field_type] # type: ignore
+ except Exception:
+ pass
+
+ safe_name = sanitize_name(original_name)
+ field_kwargs = {"description": prop.get("description", "")}
+ # If we renamed, keep external alias stable
+ if safe_name != original_name:
+ field_kwargs["validation_alias"] = original_name
+ field_kwargs["serialization_alias"] = original_name
+
+ # Preserve nested schema hints for arrays/objects so model_json_schema() retains details
+ try:
+ declared_type = prop.get("type")
+ is_array = declared_type == "array" or (isinstance(declared_type, list) and "array" in declared_type)
+ is_object = declared_type == "object" or (isinstance(declared_type, list) and "object" in declared_type)
+ json_extra: dict[str, Any] = {}
+ if is_array and "items" in prop:
+ json_extra["items"] = prop["items"]
+ for k in ("minItems", "maxItems", "uniqueItems"):
+ if k in prop:
+ json_extra[k] = prop[k]
+ if is_object and "properties" in prop:
+ json_extra["properties"] = prop["properties"]
+ if "required" in prop:
+ json_extra["required"] = prop["required"]
+ if "additionalProperties" in prop:
+ json_extra["additionalProperties"] = prop["additionalProperties"]
+ if json_extra:
+ field_kwargs["json_schema_extra"] = json_extra
+ except Exception:
+ pass
+
+ if default_value != ...:
+ fields[safe_name] = (field_type, Field(default=default_value, **field_kwargs))
+ else:
+ fields[safe_name] = (field_type, Field(**field_kwargs))
+
+ # Detect whether schema allows additionalProperties
+ additional_properties = tool_info.inputSchema.get("additionalProperties", False)
+ allow_extra = bool(additional_properties) # dict/True both considered as allowed
+
+ if not fields and allow_extra:
+ # No declared fields but open object: create permissive model with extra=allow
+ base = type("OpenArgsBase", (BaseModel,), {"model_config": ConfigDict(extra="allow")})
+ with warnings.catch_warnings():
+ # Ignore pydantic warnings about field name conflicts (handled via sanitize_name)
+ warnings.filterwarnings(
+ "ignore",
+ category=UserWarning,
+ module="pydantic",
+ )
+ return create_model(f"{tool_info.name.capitalize().replace('_', '')}Input", __base__=base)
+
+ if not fields:
+ fields["input"] = (str, Field(description="Tool input"))
+
+ # Suppress specific Pydantic warning about shadowing BaseModel attributes
+ with warnings.catch_warnings():
+ # Ignore pydantic warnings about field name conflicts (already handled by sanitize_name)
+ warnings.filterwarnings(
+ "ignore",
+ category=UserWarning,
+ module="pydantic",
+ )
+ # Create model; if open schema, allow extras
+ base = BaseModel
+ if allow_extra:
+ base = type("OpenArgsBase", (BaseModel,), {"model_config": ConfigDict(extra="allow")})
+ return create_model(f"{tool_info.name.capitalize().replace('_', '')}Input", __base__=base, **fields)
+
+
+def build_sync_executor(context: 'MCPStoreContext', tool_name: str, args_schema: Type[BaseModel]) -> Callable[..., Any]:
+ def _executor(**kwargs):
+ tool_input = {}
+ try:
+ schema_info = args_schema.model_json_schema()
+ schema_fields = schema_info.get('properties', {})
+ field_names = list(schema_fields.keys())
+ allow_extra = bool(schema_info.get('additionalProperties', False))
+ tool_input = dict(kwargs) if allow_extra else {k: v for k, v in kwargs.items() if k in field_names}
+ try:
+ validated = args_schema(**tool_input)
+ except Exception:
+ filtered = {k: kwargs[k] for k in field_names if k in kwargs}
+ validated = args_schema(**filtered)
+ result = context.call_tool(
+ tool_name,
+ validated.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+ actual = getattr(result, 'result', None)
+ if actual is None and getattr(result, 'success', False):
+ actual = getattr(result, 'data', str(result))
+ if isinstance(actual, (dict, list)):
+ return json.dumps(actual, ensure_ascii=False)
+ return str(actual)
+ except Exception as e:
+ return f"Tool '{tool_name}' execution failed: {e}\nProcessed parameters: {tool_input}"
+ _executor.__name__ = tool_name
+ _executor.__doc__ = "Auto-generated MCPStore tool wrapper"
+ return _executor
+
+
+def build_async_executor(context: 'MCPStoreContext', tool_name: str, args_schema: Type[BaseModel]) -> Callable[..., Any]:
+ async def _executor(**kwargs):
+ validated = args_schema(**kwargs)
+ result = await context.call_tool_async(
+ tool_name,
+ validated.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+ actual = getattr(result, 'result', None)
+ if actual is None and getattr(result, 'success', False):
+ actual = getattr(result, 'data', str(result))
+ if isinstance(actual, (dict, list)):
+ return json.dumps(actual, ensure_ascii=False)
+ return str(actual)
+ _executor.__name__ = tool_name
+ _executor.__doc__ = "Auto-generated MCPStore tool wrapper (async)"
+ return _executor
+
+
+def attach_signature_from_schema(fn: Callable[..., Any], args_schema: Type[BaseModel]) -> None:
+ """Attach an inspect.Signature to function based on args_schema for better introspection."""
+ schema_props = args_schema.model_json_schema().get('properties', {})
+ params = [inspect.Parameter(k, inspect.Parameter.KEYWORD_ONLY) for k in schema_props.keys()]
+ fn.__signature__ = inspect.Signature(parameters=params) # type: ignore
diff --git a/src/mcpstore/adapters/crewai_adapter.py b/src/mcpstore/adapters/crewai_adapter.py
new file mode 100644
index 00000000..b772542d
--- /dev/null
+++ b/src/mcpstore/adapters/crewai_adapter.py
@@ -0,0 +1,25 @@
+# src/mcpstore/adapters/crewai_adapter.py
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+
+class CrewAIAdapter:
+ """
+ CrewAI often consumes LangChain Tool objects directly.
+ We reuse the LangChain adapter to maximize compatibility and avoid extra deps.
+ """
+ def __init__(self, context: 'MCPStoreContext'):
+ self._context = context
+
+ def list_tools(self) -> List[object]:
+ # Defer import and reuse for_langchain output
+ lc_adapter = self._context.for_langchain()
+ return lc_adapter.list_tools()
+
+ async def list_tools_async(self) -> List[object]:
+ lc_adapter = self._context.for_langchain()
+ return await lc_adapter.list_tools_async()
+
diff --git a/src/mcpstore/adapters/langchain_adapter.py b/src/mcpstore/adapters/langchain_adapter.py
new file mode 100644
index 00000000..3f86e557
--- /dev/null
+++ b/src/mcpstore/adapters/langchain_adapter.py
@@ -0,0 +1,750 @@
+# src/mcpstore/adapters/langchain_adapter.py
+
+import json
+import keyword
+import logging
+import re
+import warnings
+from dataclasses import asdict, is_dataclass
+from typing import Type, List, TYPE_CHECKING
+
+from langchain_core.tools import Tool, StructuredTool, ToolException
+from pydantic import BaseModel, create_model, Field, ConfigDict
+
+from .common import call_tool_response_helper
+from ..core.bridge import get_async_bridge
+
+# Use TYPE_CHECKING and string hints to avoid circular imports
+if TYPE_CHECKING:
+ from ..core.context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+logger = logging.getLogger(__name__)
+
+class LangChainAdapter:
+ """
+ Adapter (bridge) between MCPStore and LangChain.
+ It converts mcpstore's native objects to objects that LangChain can directly use.
+ """
+ def __init__(self, context: 'MCPStoreContext', response_format: str = "text"):
+ self._context = context
+ # Use the unified async bridge to avoid legacy helpers and loop conflicts
+ self._bridge = get_async_bridge()
+ # Adapter-only rendering preference for tool outputs
+ self._response_format = response_format if response_format in ("text", "content_and_artifact") else "text"
+
+ @staticmethod
+ def _serialize_unknown(obj):
+ if obj is None:
+ return None
+ if hasattr(obj, "model_dump"):
+ try:
+ return obj.model_dump()
+ except Exception:
+ pass
+ if hasattr(obj, "dict"):
+ try:
+ return obj.dict()
+ except Exception:
+ pass
+ if is_dataclass(obj):
+ try:
+ return asdict(obj)
+ except Exception:
+ pass
+ if hasattr(obj, "__dict__"):
+ try:
+ return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
+ except Exception:
+ pass
+ return str(obj)
+
+ def _normalize_structured_value(self, value):
+ """
+ 确保 structured/data 字段始终是 LangChain 能消费的基础类型。
+ """
+ if value is None:
+ return None
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, (dict, list)):
+ return value
+ try:
+ return json.loads(json.dumps(value, default=self._serialize_unknown, ensure_ascii=False))
+ except Exception:
+ return str(value)
+
+ def _enhance_description(self, tool_info: 'ToolInfo') -> str:
+ """
+ (Frontend Defense) Enhance tool description, clearly guide LLM to use correct parameters in Prompt.
+ """
+ base_description = tool_info.description
+ schema_properties = tool_info.inputSchema.get("properties", {})
+
+ if not schema_properties:
+ return base_description
+
+ param_descriptions = []
+ for param_name, param_info in schema_properties.items():
+ param_type = param_info.get("type", "string")
+ param_desc = param_info.get("description", "")
+ line = f"- {param_name} ({param_type}): {param_desc}"
+ # If array of objects, describe nested shape to help the LLM
+ try:
+ if (param_type == "array" or (isinstance(param_type, list) and "array" in param_type)) and isinstance(param_info.get("items"), dict):
+ items = param_info["items"]
+ if items.get("type") == "object" and "properties" in items:
+ nested = []
+ for nkey, nprop in items["properties"].items():
+ ntype = nprop.get("type", "string")
+ ndesc = nprop.get("description", "")
+ nested.append(f" - {param_name}[].{nkey} ({ntype}) {ndesc}")
+ if nested:
+ line += "\n" + "\n".join(nested)
+ except Exception:
+ pass
+ param_descriptions.append(line)
+
+ # Append parameter descriptions to main description
+ enhanced_desc = base_description + "\n\nParameter descriptions:\n" + "\n".join(param_descriptions)
+ return enhanced_desc
+
+ def _create_args_schema(self, tool_info: 'ToolInfo') -> Type[BaseModel]:
+ """(Data Conversion) Create Pydantic model from ToolInfo, avoiding BaseModel attribute name collisions (e.g., 'schema')."""
+ schema_properties = tool_info.inputSchema.get("properties", {})
+ required_fields = tool_info.inputSchema.get("required", [])
+
+ type_mapping = {
+ "string": str, "number": float, "integer": int,
+ "boolean": bool, "array": list, "object": dict
+ }
+
+ # Reserved names that should not be used as field identifiers
+ reserved_names = set(dir(BaseModel)) | {
+ "schema", "model_json_schema", "model_dump", "dict", "json",
+ "copy", "parse_obj", "parse_raw", "construct", "validate",
+ "schema_json", "__fields__", "__root__", "Config", "model_config",
+ }
+
+ def sanitize_name(original: str) -> str:
+ """
+ Convert any parameter name to a valid Python identifier.
+ - Replace non-alphanumeric/underscore characters with underscores
+ - Add prefix if starts with digit
+ - Add suffix if Python keyword or reserved name
+ """
+ # 1. Replace all invalid characters with underscores
+ safe = re.sub(r'[^a-zA-Z0-9_]', '_', original)
+
+ # 2. If starts with digit, add prefix
+ if safe and safe[0].isdigit():
+ safe = f"param_{safe}"
+
+ # 3. If Python keyword or reserved name, add suffix
+ if keyword.iskeyword(safe) or safe in reserved_names or safe.startswith("_"):
+ safe = f"{safe}_"
+
+ # 4. Ensure not empty and is valid identifier
+ if not safe or not safe.isidentifier():
+ safe = "param_"
+
+ return safe
+
+ # Intelligently build field definitions with alias mapping
+ fields = {}
+ for original_name, prop in schema_properties.items():
+ field_type = type_mapping.get(prop.get("type", "string"), str)
+
+ # Detect JSON Schema nullability/Optional
+ def _is_nullable(p: dict) -> bool:
+ try:
+ if p.get("nullable") is True:
+ return True
+ t = p.get("type")
+ if isinstance(t, list) and "null" in t:
+ return True
+ any_of = p.get("anyOf") or []
+ if isinstance(any_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in any_of):
+ return True
+ one_of = p.get("oneOf") or []
+ if isinstance(one_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in one_of):
+ return True
+ except Exception:
+ pass
+ return False
+
+ is_nullable = _is_nullable(prop)
+ is_required = original_name in required_fields
+
+ # Make non-required fields truly optional by defaulting to None (sentinel)
+ default_value = prop.get("default", ...)
+ if not is_required and default_value == ...:
+ default_value = None
+
+ safe_name = sanitize_name(original_name)
+ field_kwargs = {"description": prop.get("description", "")}
+ if safe_name != original_name:
+ field_kwargs["validation_alias"] = original_name
+ field_kwargs["serialization_alias"] = original_name
+
+ # Apply Optional typing if nullable
+ try:
+ if is_nullable and field_type is not Any:
+ from typing import Optional as _Optional
+ field_type = _Optional[field_type] # type: ignore
+ except Exception:
+ pass
+
+ # Preserve nested schema hints (arrays/objects) so model_json_schema() includes them
+ try:
+ declared_type = prop.get("type")
+ is_array = declared_type == "array" or (isinstance(declared_type, list) and "array" in declared_type)
+ is_object = declared_type == "object" or (isinstance(declared_type, list) and "object" in declared_type)
+ json_extra: dict[str, Any] = {}
+ if is_array and "items" in prop:
+ json_extra["items"] = prop["items"]
+ for k in ("minItems", "maxItems", "uniqueItems"):
+ if k in prop:
+ json_extra[k] = prop[k]
+ if is_object and "properties" in prop:
+ json_extra["properties"] = prop["properties"]
+ if "required" in prop:
+ json_extra["required"] = prop["required"]
+ if "additionalProperties" in prop:
+ json_extra["additionalProperties"] = prop["additionalProperties"]
+ if json_extra:
+ field_kwargs["json_schema_extra"] = json_extra
+ except Exception:
+ pass
+
+ # Build field definition
+ if default_value != ...:
+ fields[safe_name] = (field_type, Field(default=default_value, **field_kwargs))
+ else:
+ fields[safe_name] = (field_type, Field(**field_kwargs))
+
+ # [FIX] Allow empty model, don't force adding fields
+ # For truly no-parameter tools, create empty BaseModel
+
+ # Determine open schema (additionalProperties)
+ additional_properties = tool_info.inputSchema.get("additionalProperties", False)
+ allow_extra = bool(additional_properties)
+
+ with warnings.catch_warnings():
+ # Ignore pydantic warnings about field name conflicts (handled via sanitize_name)
+ warnings.filterwarnings(
+ "ignore",
+ category=UserWarning,
+ module="pydantic",
+ )
+ base = BaseModel
+ if allow_extra:
+ base = type("OpenArgsBase", (BaseModel,), {"model_config": ConfigDict(extra="allow")})
+ return create_model(
+ f'{tool_info.name.capitalize().replace("_", "")}Input',
+ __base__=base,
+ **fields
+ )
+
+ def _create_tool_function(self, tool_name: str, args_schema: Type[BaseModel]):
+ """
+ (Backend Guard) Create a robust synchronous execution function, intelligently handle various parameter passing methods.
+ """
+ def _tool_executor(*args, **kwargs):
+ tool_input = {}
+ try:
+ # Get model field information
+ schema_info = args_schema.model_json_schema()
+ schema_fields = schema_info.get('properties', {})
+ field_names = list(schema_fields.keys())
+
+ # [FIX] Handle no-parameter tools
+ if not field_names:
+ # Truly no-parameter tools, ignore all input parameters
+ tool_input = {}
+ else:
+ # Intelligent parameter processing for tools with parameters
+ if kwargs:
+ # Keyword argument method (recommended)
+ tool_input = kwargs
+ elif args:
+ if len(args) == 1:
+ # Single parameter processing
+ if isinstance(args[0], dict):
+ # Dictionary parameter
+ tool_input = args[0]
+ else:
+ # Single value parameter, map to first field
+ if field_names:
+ tool_input = {field_names[0]: args[0]}
+ else:
+ # Multiple positional parameters, map to fields in order
+ for i, arg_value in enumerate(args):
+ if i < len(field_names):
+ tool_input[field_names[i]] = arg_value
+
+ # Intelligently fill missing required parameters
+ for field_name, field_info in schema_fields.items():
+ if field_name not in tool_input and 'default' in field_info:
+ tool_input[field_name] = field_info['default']
+
+ # Use Pydantic model to validate parameters
+ try:
+ validated_args = args_schema(**tool_input)
+ except Exception as validation_error:
+ # If validation fails, try more lenient processing
+ filtered_input = {}
+ for field_name in field_names:
+ if field_name in tool_input:
+ filtered_input[field_name] = tool_input[field_name]
+ validated_args = args_schema(**filtered_input)
+
+ # Call mcpstore's core method
+ result = self._context.call_tool(
+ tool_name,
+ validated_args.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+
+ view = call_tool_response_helper(result)
+
+ if view.is_error:
+ raise ToolException(view.error_message or view.text or "Tool execution failed")
+
+ if getattr(self, "_response_format", "text") == "content_and_artifact":
+ response = {"text": view.text, "artifacts": view.artifacts}
+ structured = self._normalize_structured_value(view.structured)
+ data = self._normalize_structured_value(view.data)
+ if structured is not None:
+ response["structured"] = structured
+ if data is not None:
+ response["data"] = data
+ return response
+
+ return view.text
+ except Exception as e:
+ # Provide more detailed error information for debugging
+ error_msg = f"Tool '{tool_name}' execution failed: {str(e)}"
+ if args or kwargs:
+ error_msg += f"\nParameter info: args={args}, kwargs={kwargs}"
+ if tool_input:
+ error_msg += f"\nProcessed parameters: {tool_input}"
+ return error_msg
+ return _tool_executor
+
+ async def _create_tool_coroutine(self, tool_name: str, args_schema: Type[BaseModel]):
+ """
+ (Backend Guard) Create a robust asynchronous execution function, intelligently handle various parameter passing methods.
+ """
+ async def _tool_executor(*args, **kwargs):
+ tool_input = {}
+ try:
+ # Get model field information
+ schema_info = args_schema.model_json_schema()
+ schema_fields = schema_info.get('properties', {})
+ field_names = list(schema_fields.keys())
+
+ # [FIX] Handle no-parameter tools (same logic as sync version)
+ if not field_names:
+ # Truly no-parameter tools, ignore all input parameters
+ tool_input = {}
+ else:
+ # Intelligent parameter processing
+ if kwargs:
+ tool_input = kwargs
+ elif args:
+ if len(args) == 1:
+ if isinstance(args[0], dict):
+ tool_input = args[0]
+ else:
+ if field_names:
+ tool_input = {field_names[0]: args[0]}
+ else:
+ for i, arg_value in enumerate(args):
+ if i < len(field_names):
+ tool_input[field_names[i]] = arg_value
+
+ # Intelligently fill missing required parameters
+ for field_name, field_info in schema_fields.items():
+ if field_name not in tool_input and 'default' in field_info:
+ tool_input[field_name] = field_info['default']
+
+ # Use Pydantic model to validate parameters
+ try:
+ validated_args = args_schema(**tool_input)
+ except Exception as validation_error:
+ filtered_input = {}
+ for field_name in field_names:
+ if field_name in tool_input:
+ filtered_input[field_name] = tool_input[field_name]
+ validated_args = args_schema(**filtered_input)
+
+ # Call mcpstore core method (async version)
+ result = await self._context.call_tool_async(
+ tool_name,
+ validated_args.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+
+ view = call_tool_response_helper(result)
+
+ if view.is_error:
+ raise ToolException(view.error_message or view.text or "Tool execution failed")
+
+ if getattr(self, "_response_format", "text") == "content_and_artifact":
+ response = {"text": view.text, "artifacts": view.artifacts}
+ structured = self._normalize_structured_value(view.structured)
+ data = self._normalize_structured_value(view.data)
+ if structured is not None:
+ response["structured"] = structured
+ if data is not None:
+ response["data"] = data
+ return response
+
+ return view.text
+ except Exception as e:
+ error_msg = f"Tool '{tool_name}' execution failed: {str(e)}"
+ if args or kwargs:
+ error_msg += f"\nParameter info: args={args}, kwargs={kwargs}"
+ if tool_input:
+ error_msg += f"\nProcessed parameters: {tool_input}"
+ return error_msg
+ return _tool_executor
+
+ def list_tools(self) -> List[Tool]:
+ """Get all available mcpstore tools and convert them to LangChain Tool list (synchronous version)."""
+ return self._bridge.run(self.list_tools_async(), op_name="LangChainAdapter.list_tools")
+
+ async def list_tools_async(self) -> List[Tool]:
+ """
+ Get all available mcpstore tools and convert them to LangChain Tool list (asynchronous version).
+
+ Raises:
+ RuntimeError: If no tools available (all services failed to connect)
+ """
+ mcp_tools_info = await self._context.list_tools_async()
+
+ # [CHECK] If tools are empty, provide friendly error message
+ if not mcp_tools_info:
+ logger.warning("[LIST_TOOLS] empty=True")
+ # Check service status, provide more detailed hints
+ services = await self._context.list_services_async()
+ if not services:
+ raise RuntimeError(
+ "No available tools: No MCP services have been added. "
+ "Please add services using add_service() first."
+ )
+ else:
+ # Services exist but no tools, indicates services failed to connect
+ failed_services = [s.name for s in services if s.status.value != 'healthy']
+ if failed_services:
+ raise RuntimeError(
+ f"No available tools: The following services failed to connect: {', '.join(failed_services)}. "
+ f"Please check service configuration and dependencies, or use wait_service() to wait for services to be ready. "
+ f"\nTip: You can use list_services() to view detailed service status."
+ )
+ else:
+ raise RuntimeError(
+ "No available tools: Services are connected but provide no tools. "
+ "Please check if services are working properly."
+ )
+
+ langchain_tools = []
+ for tool_info in mcp_tools_info:
+ enhanced_description = self._enhance_description(tool_info)
+ args_schema = self._create_args_schema(tool_info)
+
+ # Create synchronous and asynchronous functions
+ sync_func = self._create_tool_function(tool_info.name, args_schema)
+ async_coroutine = await self._create_tool_coroutine(tool_info.name, args_schema)
+
+ # [FIX] Determine parameter count based on original schema, not converted
+ schema_properties = tool_info.inputSchema.get("properties", {})
+ original_param_count = len(schema_properties)
+
+ # Read per-tool overrides (e.g., return_direct) from context
+ try:
+ return_direct_flag = self._context._get_tool_override(tool_info.service_name, tool_info.name, "return_direct", False)
+ except Exception:
+ return_direct_flag = False
+
+ # [CRITICAL FIX] For no-parameter tools, also use StructuredTool
+ # Although they have no parameters, StructuredTool's parameter processing is more reliable
+ # Tool type has special handling for empty dict {}, which may cause parameter conversion issues
+ if original_param_count >= 1:
+ # Multi-parameter tools use StructuredTool
+ lc_tool = StructuredTool(
+ name=tool_info.name,
+ description=enhanced_description,
+ func=sync_func,
+ coroutine=async_coroutine,
+ args_schema=args_schema,
+ )
+ else:
+ # [FIX] No-parameter tools also use StructuredTool to avoid parameter conversion issues
+ # This ensures {} is correctly handled and not converted to []
+ lc_tool = StructuredTool(
+ name=tool_info.name,
+ description=enhanced_description,
+ func=sync_func,
+ coroutine=async_coroutine,
+ args_schema=args_schema,
+ )
+
+ # Set return_direct if supported
+ try:
+ setattr(lc_tool, 'return_direct', bool(return_direct_flag))
+ except Exception:
+ pass
+ langchain_tools.append(lc_tool)
+ return langchain_tools
+
+
+class SessionAwareLangChainAdapter(LangChainAdapter):
+ """
+ Session-aware LangChain adapter
+
+ This enhanced adapter creates LangChain tools that are bound to a specific session,
+ ensuring state persistence across multiple tool calls in LangChain agent workflows.
+
+ Key features:
+ - Tools automatically use session-bound execution
+ - State preservation across tool calls (e.g., browser stays open)
+ - Seamless integration with existing LangChain workflows
+ - Backward compatible with standard LangChainAdapter
+ """
+
+ def __init__(self, context: 'MCPStoreContext', session: 'Session', response_format: str = "text"):
+ """
+ Initialize session-aware adapter
+
+ Args:
+ context: MCPStoreContext instance (for tool discovery)
+ session: Session object that tools will be bound to
+ response_format: Same as LangChainAdapter ("text" or "content_and_artifact")
+ """
+ super().__init__(context, response_format=response_format)
+ self._session = session
+
+ logger.debug(f"Initialized session-aware adapter for session '{session.session_id}'")
+
+ def _create_tool_function(self, tool_name: str, args_schema: Type[BaseModel]):
+ """
+ Create session-bound tool function
+
+ This overrides the parent method to route tool execution through the session,
+ ensuring state persistence across multiple tool calls.
+ """
+ def _session_tool_executor(*args, **kwargs):
+ tool_input = {}
+ try:
+ # [REUSE] Parent's intelligent parameter processing
+ schema_info = args_schema.model_json_schema()
+ schema_fields = schema_info.get('properties', {})
+ field_names = list(schema_fields.keys())
+
+ # [FIX] Handle no-parameter tools (same logic as parent class)
+ if not field_names:
+ # Truly no-parameter tools, ignore all input parameters
+ tool_input = {}
+ else:
+ # Intelligent parameter processing (same as parent)
+ if kwargs:
+ tool_input = kwargs
+ elif args:
+ if len(args) == 1:
+ if isinstance(args[0], dict):
+ tool_input = args[0]
+ else:
+ if field_names:
+ tool_input = {field_names[0]: args[0]}
+ else:
+ for i, arg_value in enumerate(args):
+ if i < len(field_names):
+ tool_input[field_names[i]] = arg_value
+
+ # Intelligently fill missing required parameters (same as parent)
+ for field_name, field_info in schema_fields.items():
+ if field_name not in tool_input and 'default' in field_info:
+ tool_input[field_name] = field_info['default']
+
+ # Validate parameters (same as parent)
+ try:
+ validated_args = args_schema(**tool_input)
+ except Exception as validation_error:
+ filtered_input = {}
+ for field_name in field_names:
+ if field_name in tool_input:
+ filtered_input[field_name] = tool_input[field_name]
+ validated_args = args_schema(**filtered_input)
+
+ # [KEY] Use session-bound execution instead of context.call_tool
+ logger.debug(f"[SESSION_LANGCHAIN] Executing tool '{tool_name}' via session '{self._session.session_id}'")
+ result = self._session.use_tool(
+ tool_name,
+ validated_args.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+
+ view = call_tool_response_helper(result)
+
+ if view.is_error:
+ raise ToolException(view.error_message or view.text or "Tool execution failed")
+
+ if getattr(self, "_response_format", "text") == "content_and_artifact":
+ response = {"text": view.text, "artifacts": view.artifacts}
+ structured = self._normalize_structured_value(view.structured)
+ data = self._normalize_structured_value(view.data)
+ if structured is not None:
+ response["structured"] = structured
+ if data is not None:
+ response["data"] = data
+ return response
+
+ return view.text
+
+ except Exception as e:
+ error_msg = f"Tool execution failed: {str(e)}"
+ logger.error(f"[SESSION_LANGCHAIN] {error_msg}")
+ return error_msg
+
+ return _session_tool_executor
+
+ def _create_async_tool_function(self, tool_name: str, args_schema: Type[BaseModel]):
+ """
+ Create session-bound async tool function
+ """
+ async def _session_async_tool_executor(*args, **kwargs):
+ tool_input = {}
+ try:
+ # [SAME] Parameter processing as sync version
+ schema_info = args_schema.model_json_schema()
+ schema_fields = schema_info.get('properties', {})
+ field_names = list(schema_fields.keys())
+
+ # [FIX] Handle no-parameter tools (same logic as sync version)
+ if not field_names:
+ # Truly no-parameter tools, ignore all input parameters
+ tool_input = {}
+ else:
+ if kwargs:
+ tool_input = kwargs
+ elif args:
+ if len(args) == 1:
+ if isinstance(args[0], dict):
+ tool_input = args[0]
+ else:
+ if field_names:
+ tool_input = {field_names[0]: args[0]}
+ else:
+ for i, arg_value in enumerate(args):
+ if i < len(field_names):
+ tool_input[field_names[i]] = arg_value
+
+ for field_name, field_info in schema_fields.items():
+ if field_name not in tool_input and 'default' in field_info:
+ tool_input[field_name] = field_info['default']
+
+ try:
+ validated_args = args_schema(**tool_input)
+ except Exception as validation_error:
+ filtered_input = {}
+ for field_name in field_names:
+ if field_name in tool_input:
+ filtered_input[field_name] = tool_input[field_name]
+ validated_args = args_schema(**filtered_input)
+
+ # [KEY] Use session-bound async execution
+ logger.debug(f"[SESSION_LANGCHAIN] Executing tool '{tool_name}' via session '{self._session.session_id}' (async)")
+ result = await self._session.use_tool_async(
+ tool_name,
+ validated_args.model_dump(
+ by_alias=True, # Use original parameter names
+ exclude_unset=True, # Don't send unset parameters
+ exclude_none=False, # Preserve explicit None values
+ exclude_defaults=False # Preserve default values (service may require them)
+ )
+ )
+
+ view = call_tool_response_helper(result)
+
+ if view.is_error:
+ raise ToolException(view.error_message or view.text or "Tool execution failed")
+
+ if getattr(self, "_response_format", "text") == "content_and_artifact":
+ response = {"text": view.text, "artifacts": view.artifacts}
+ structured = self._normalize_structured_value(view.structured)
+ data = self._normalize_structured_value(view.data)
+ if structured is not None:
+ response["structured"] = structured
+ if data is not None:
+ response["data"] = data
+ return response
+
+ return view.text
+
+ except Exception as e:
+ error_msg = f"Async tool execution failed: {str(e)}"
+ logger.error(f"[SESSION_LANGCHAIN] {error_msg}")
+ return error_msg
+
+ return _session_async_tool_executor
+
+ async def list_tools_async(self) -> List[Tool]:
+ """
+ Create session-bound LangChain tools (async version)
+
+ Returns:
+ List of LangChain Tool objects bound to the session
+ """
+ logger.debug(f"Creating session-bound tools for session '{self._session.session_id}'")
+
+ # Use parent's tool discovery logic
+ mcpstore_tools = await self._context.list_tools_async()
+ langchain_tools = []
+
+ for tool_info in mcpstore_tools:
+ # Create args schema (same as parent)
+ args_schema = self._create_args_schema(tool_info)
+
+ # Enhance description (same as parent)
+ enhanced_description = self._enhance_description(tool_info)
+
+ # [CREATE] Session-bound functions
+ sync_func = self._create_tool_function(tool_info.name, args_schema)
+ async_coroutine = self._create_async_tool_function(tool_info.name, args_schema)
+
+ # Create LangChain tool with session binding
+ langchain_tools.append(
+ StructuredTool(
+ name=tool_info.name,
+ description=enhanced_description + f" [Session: {self._session.session_id}]",
+ func=sync_func,
+ coroutine=async_coroutine,
+ args_schema=args_schema,
+ )
+ )
+
+ logger.debug(f"Created {len(langchain_tools)} session-bound tools")
+ return langchain_tools
+
+ def list_tools(self) -> List[Tool]:
+ """
+ Create session-bound LangChain tools (sync version)
+
+ Returns:
+ List of LangChain Tool objects bound to the session
+ """
+ return self._context._bridge.run(self.list_tools_async(), op_name="LangChainAdapter.list_tools_for_session")
diff --git a/src/mcpstore/adapters/langgraph_adapter.py b/src/mcpstore/adapters/langgraph_adapter.py
new file mode 100644
index 00000000..b02a822d
--- /dev/null
+++ b/src/mcpstore/adapters/langgraph_adapter.py
@@ -0,0 +1,25 @@
+# src/mcpstore/adapters/langgraph_adapter.py
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+
+class LangGraphAdapter:
+ """
+ LangGraph uses LangChain tool ecosystem under the hood.
+ We reuse the LangChain adapter output for zero extra dependency.
+ """
+ def __init__(self, context: 'MCPStoreContext', response_format: str = "text"):
+ self._context = context
+ self._response_format = response_format if response_format in ("text", "content_and_artifact") else "text"
+
+ def list_tools(self) -> List[object]:
+ lc_adapter = self._context.for_langchain(response_format=self._response_format)
+ return lc_adapter.list_tools()
+
+ async def list_tools_async(self) -> List[object]:
+ lc_adapter = self._context.for_langchain(response_format=self._response_format)
+ return await lc_adapter.list_tools_async()
+
diff --git a/src/mcpstore/adapters/llamaindex_adapter.py b/src/mcpstore/adapters/llamaindex_adapter.py
new file mode 100644
index 00000000..96962066
--- /dev/null
+++ b/src/mcpstore/adapters/llamaindex_adapter.py
@@ -0,0 +1,42 @@
+# src/mcpstore/adapters/llamaindex_adapter.py
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING
+
+from .common import enhance_description, create_args_schema, build_sync_executor
+
+# TYPE_CHECKING to avoid runtime circular imports
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+
+
+
+class LlamaIndexAdapter:
+ """
+ Adapter from MCPStore ToolInfo -> LlamaIndex FunctionTool list.
+ """
+ def __init__(self, context: 'MCPStoreContext'):
+ self._context = context
+
+ def list_tools(self) -> List[object]:
+ return self._context._sync_helper.run_async(self.list_tools_async())
+
+ async def list_tools_async(self) -> List[object]:
+ try:
+ from llama_index.core.tools import FunctionTool
+ except Exception as e:
+ raise ImportError("LlamaIndex adapter requires 'llama-index' (llama_index). Install: pip install llama-index") from e
+
+ mcp_tools: List['ToolInfo'] = await self._context.list_tools_async()
+ tools: List[object] = []
+ for t in mcp_tools:
+ args_schema = create_args_schema(t)
+ sync_fn = build_sync_executor(self._context, t.name, args_schema)
+ desc = enhance_description(t)
+ # LlamaIndex primarily accepts sync functions; name/description can be set via from_defaults
+ li_tool = FunctionTool.from_defaults(fn=sync_fn, name=t.name, description=desc)
+ tools.append(li_tool)
+ return tools
+
diff --git a/src/mcpstore/adapters/openai_adapter.py b/src/mcpstore/adapters/openai_adapter.py
new file mode 100644
index 00000000..42694d49
--- /dev/null
+++ b/src/mcpstore/adapters/openai_adapter.py
@@ -0,0 +1,296 @@
+# src/mcpstore/adapters/openai_adapter.py
+
+from __future__ import annotations
+
+import json
+from typing import List, Dict, Any, TYPE_CHECKING
+
+from .common import enhance_description, create_args_schema, build_sync_executor, build_async_executor
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+
+class OpenAIAdapter:
+ """
+ Adapter that converts MCPStore tools to OpenAI function calling format.
+ Compatible with langchain-openai's bind_tools method and direct OpenAI API.
+ """
+
+ def __init__(self, context: 'MCPStoreContext'):
+ self._context = context
+
+ def list_tools(self) -> List[Dict[str, Any]]:
+ """Get all available MCPStore tools and convert them to OpenAI function format (synchronous version)."""
+ return self._context._sync_helper.run_async(self.list_tools_async())
+
+ async def list_tools_async(self) -> List[Dict[str, Any]]:
+ """Get all available MCPStore tools and convert them to OpenAI function format (asynchronous version)."""
+ mcp_tools_info = await self._context.list_tools_async()
+ openai_tools = []
+
+ for tool_info in mcp_tools_info:
+ openai_tool = self._convert_to_openai_format(tool_info)
+ openai_tools.append(openai_tool)
+
+ return openai_tools
+
+ def _convert_to_openai_format(self, tool_info: 'ToolInfo') -> Dict[str, Any]:
+ """
+ Convert MCPStore ToolInfo to OpenAI function calling format.
+
+ OpenAI function format:
+ {
+ "type": "function",
+ "function": {
+ "name": "function_name",
+ "description": "Function description",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "param1": {
+ "type": "string",
+ "description": "Parameter description"
+ }
+ },
+ "required": ["param1"]
+ }
+ }
+ }
+ """
+ # Enhance description
+ enhanced_description = enhance_description(tool_info)
+
+ # Get input parameter schema
+ input_schema = tool_info.inputSchema or {}
+ properties = input_schema.get("properties", {})
+ required = input_schema.get("required", [])
+
+ # Convert parameter schema to OpenAI format
+ openai_parameters = {
+ "type": "object",
+ "properties": {},
+ "required": required
+ }
+
+ # Pass through top-level additionalProperties (e.g., to allow open fields)
+ if "additionalProperties" in input_schema:
+ openai_parameters["additionalProperties"] = input_schema["additionalProperties"]
+
+ # Process each parameter
+ def _is_nullable(p: Dict[str, Any]) -> bool:
+ try:
+ if p.get("nullable") is True:
+ return True
+ t = p.get("type")
+ if isinstance(t, list) and "null" in t:
+ return True
+ any_of = p.get("anyOf") or []
+ if isinstance(any_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in any_of):
+ return True
+ one_of = p.get("oneOf") or []
+ if isinstance(one_of, list) and any((isinstance(x, dict) and x.get("type") == "null") for x in one_of):
+ return True
+ if p.get("default", object()) is None:
+ return True
+ except Exception:
+ pass
+ return False
+
+ def _process_schema(p: Dict[str, Any]) -> Dict[str, Any]:
+ """Recursively process JSON Schema node into OpenAI-compatible schema."""
+ out: Dict[str, Any] = {}
+ declared_type = p.get("type", "string")
+ nullable = _is_nullable(p)
+ if nullable:
+ base_type = declared_type if isinstance(declared_type, str) else next((t for t in declared_type if t != "null"), "string")
+ out["anyOf"] = [{"type": base_type}, {"type": "null"}]
+ else:
+ out["type"] = declared_type
+ if "enum" in p:
+ out["enum"] = p["enum"]
+ if "default" in p:
+ out["default"] = p["default"]
+ # Arrays
+ if (declared_type == "array" or (isinstance(declared_type, list) and "array" in declared_type)) and "items" in p:
+ out["items"] = _process_schema(p["items"]) if isinstance(p["items"], dict) else p["items"]
+ for k in ("minItems", "maxItems", "uniqueItems"):
+ if k in p:
+ out[k] = p[k]
+ # Objects
+ is_object_type = declared_type == "object" or (isinstance(declared_type, list) and "object" in declared_type)
+ if is_object_type and "properties" in p:
+ out["properties"] = {}
+ for child_name, child_schema in p["properties"].items():
+ if isinstance(child_schema, dict):
+ out["properties"][child_name] = _process_schema(child_schema)
+ else:
+ out["properties"][child_name] = child_schema
+ if "required" in p:
+ out["required"] = p["required"]
+ if "additionalProperties" in p:
+ out["additionalProperties"] = p["additionalProperties"]
+ return out
+
+ for param_name, param_info in properties.items():
+ declared_type = param_info.get("type", "string")
+ openai_param: Dict[str, Any] = {"description": param_info.get("description", "")}
+ # Merge processed schema (type/anyOf, enum/default, nested items/properties)
+ openai_param.update(_process_schema(param_info))
+ openai_parameters["properties"][param_name] = openai_param
+
+ # If no parameters, create an empty parameter structure
+ if not properties:
+ openai_parameters = {
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+
+ # Build OpenAI function format
+ openai_tool = {
+ "type": "function",
+ "function": {
+ "name": tool_info.name,
+ "description": enhanced_description,
+ "parameters": openai_parameters
+ }
+ }
+
+ return openai_tool
+
+ def get_callable_tools(self) -> List[Dict[str, Any]]:
+ """
+ Get tools with callable functions for direct execution.
+ Returns a list of dicts with 'tool' (OpenAI format) and 'callable' (execution function).
+ """
+ return self._context._sync_helper.run_async(self.get_callable_tools_async())
+
+ async def get_callable_tools_async(self) -> List[Dict[str, Any]]:
+ """
+ Get tools with callable functions for direct execution (async version).
+ """
+ mcp_tools_info = await self._context.list_tools_async()
+ callable_tools = []
+
+ for tool_info in mcp_tools_info:
+ # Convert to OpenAI format
+ openai_tool = self._convert_to_openai_format(tool_info)
+
+ # Create parameter schema
+ args_schema = create_args_schema(tool_info)
+
+ # Create callable functions
+ sync_executor = build_sync_executor(self._context, tool_info.name, args_schema)
+ async_executor = build_async_executor(self._context, tool_info.name, args_schema)
+
+ callable_tools.append({
+ "tool": openai_tool,
+ "callable": sync_executor,
+ "async_callable": async_executor,
+ "name": tool_info.name,
+ "schema": args_schema
+ })
+
+ return callable_tools
+
+ def create_tool_registry(self) -> Dict[str, Any]:
+ """
+ Create a tool registry for easy tool execution by name.
+ Returns a dict mapping tool names to their executors and metadata.
+ """
+ return self._context._sync_helper.run_async(self.create_tool_registry_async())
+
+ async def create_tool_registry_async(self) -> Dict[str, Any]:
+ """
+ Create a tool registry for easy tool execution by name (async version).
+ """
+ callable_tools = await self.get_callable_tools_async()
+ registry = {}
+
+ for tool_data in callable_tools:
+ tool_name = tool_data["name"]
+ registry[tool_name] = {
+ "openai_format": tool_data["tool"],
+ "execute": tool_data["callable"],
+ "execute_async": tool_data["async_callable"],
+ "schema": tool_data["schema"]
+ }
+
+ return registry
+
+ def execute_tool_call(self, tool_call: Dict[str, Any]) -> str:
+ """
+ Execute a tool call from OpenAI response format.
+
+ Args:
+ tool_call: OpenAI tool call format with 'name' and 'arguments'
+
+ Returns:
+ str: Tool execution result
+ """
+ return self._context._sync_helper.run_async(self.execute_tool_call_async(tool_call))
+
+ async def execute_tool_call_async(self, tool_call: Dict[str, Any]) -> str:
+ """
+ Execute a tool call from OpenAI response format (async version).
+ """
+ try:
+ tool_name = tool_call.get("name") or tool_call.get("function", {}).get("name")
+ arguments = tool_call.get("arguments") or tool_call.get("function", {}).get("arguments", {})
+
+ if not tool_name:
+ raise ValueError("Tool name not found in tool_call")
+
+ # If arguments is a string, try to parse as JSON
+ if isinstance(arguments, str):
+ try:
+ arguments = json.loads(arguments)
+ except json.JSONDecodeError:
+ arguments = {}
+
+ # Call tool
+ result = await self._context.call_tool_async(tool_name, arguments)
+
+ # Extract actual result
+ if hasattr(result, 'result') and result.result is not None:
+ actual_result = result.result
+ elif hasattr(result, 'success') and result.success:
+ actual_result = getattr(result, 'data', str(result))
+ else:
+ actual_result = str(result)
+
+ # Format output
+ if isinstance(actual_result, (dict, list)):
+ return json.dumps(actual_result, ensure_ascii=False)
+ return str(actual_result)
+
+ except Exception as e:
+ error_msg = f"Tool '{tool_name}' execution failed: {str(e)}"
+ return error_msg
+
+ def batch_execute_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[str]:
+ """
+ Execute multiple tool calls in batch.
+
+ Args:
+ tool_calls: List of OpenAI tool call formats
+
+ Returns:
+ List[str]: List of tool execution results
+ """
+ return self._context._sync_helper.run_async(self.batch_execute_tool_calls_async(tool_calls))
+
+ async def batch_execute_tool_calls_async(self, tool_calls: List[Dict[str, Any]]) -> List[str]:
+ """
+ Execute multiple tool calls in batch (async version).
+ """
+ results = []
+ for tool_call in tool_calls:
+ try:
+ result = await self.execute_tool_call_async(tool_call)
+ results.append(result)
+ except Exception as e:
+ results.append(f"Error executing tool call: {str(e)}")
+ return results
diff --git a/src/mcpstore/adapters/semantic_kernel_adapter.py b/src/mcpstore/adapters/semantic_kernel_adapter.py
new file mode 100644
index 00000000..678f6ac8
--- /dev/null
+++ b/src/mcpstore/adapters/semantic_kernel_adapter.py
@@ -0,0 +1,34 @@
+# src/mcpstore/adapters/semantic_kernel_adapter.py
+from __future__ import annotations
+
+from typing import List, TYPE_CHECKING, Callable, Any
+
+from .common import create_args_schema, build_sync_executor
+
+if TYPE_CHECKING:
+ from ..core.context.base_context import MCPStoreContext
+ from ..core.models.tool import ToolInfo
+
+
+
+
+class SemanticKernelAdapter:
+ """
+ Produce Python callables that can be registered as native functions in Semantic Kernel.
+ Caller can register them into Kernel/Plugin as needed.
+ """
+ def __init__(self, context: 'MCPStoreContext'):
+ self._context = context
+
+ def list_tools(self) -> List[Callable[..., Any]]:
+ return self._context._sync_helper.run_async(self.list_tools_async())
+
+ async def list_tools_async(self) -> List[Callable[..., Any]]:
+ tools: List[Callable[..., Any]] = []
+ mcp_tools: List['ToolInfo'] = await self._context.list_tools_async()
+ for t in mcp_tools:
+ args_schema = create_args_schema(t)
+ fn = build_sync_executor(self._context, t.name, args_schema)
+ tools.append(fn)
+ return tools
+
diff --git a/src/mcpstore/cli/__init__.py b/src/mcpstore/cli/__init__.py
index e69de29b..4b7baeff 100644
--- a/src/mcpstore/cli/__init__.py
+++ b/src/mcpstore/cli/__init__.py
@@ -0,0 +1,6 @@
+"""
+MCPStore CLI Package
+"""
+from .main import main
+
+__all__ = ["main"]
diff --git a/src/mcpstore/cli/config_manager.py b/src/mcpstore/cli/config_manager.py
new file mode 100644
index 00000000..b732f5b9
--- /dev/null
+++ b/src/mcpstore/cli/config_manager.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+"""
+MCPStore Configuration Manager - Configuration file management tool
+"""
+import json
+import platform
+from pathlib import Path
+from typing import Dict, Any, Optional, List
+
+import typer
+
+
+# Configuration constants
+class ConfigConstants:
+ """Configuration related constants"""
+ DEFAULT_VERSION = "1.0.0"
+ CONFIG_FILENAME = "mcp.json"
+ APP_NAME = "mcpstore"
+
+ # UI constants
+ SEPARATOR_LENGTH = 50
+ SEPARATOR_CHAR = "─"
+
+ # Supported service types
+ SUPPORTED_TRANSPORTS = ["streamable-http", "sse", "stdio"]
+
+ # Required field mapping
+ REQUIRED_FIELDS = {
+ "url": ["url"], # Required fields for URL services
+ "command": ["command"], # Required fields for command services
+ }
+
+
+def _get_system_config_dir() -> Path:
+ """Get system configuration directory (cross-platform)"""
+ system = platform.system().lower()
+
+ if system == "windows":
+ # Windows: C:\\ProgramData\\mcpstore (fixed path)
+ program_data = "C:\\ProgramData"
+ return Path(program_data) / ConfigConstants.APP_NAME
+ elif system == "darwin":
+ # macOS: /Library/Application Support/mcpstore
+ return Path("/Library/Application Support") / ConfigConstants.APP_NAME
+ else:
+ # Linux/Unix: /etc/mcpstore
+ return Path("/etc") / ConfigConstants.APP_NAME
+
+def get_default_config_path() -> Path:
+ """Get default configuration file path (search by priority)"""
+ search_paths = [
+ # 1. Current working directory
+ Path.cwd() / ConfigConstants.CONFIG_FILENAME,
+ # 2. User configuration directory
+ Path.home() / f".{ConfigConstants.APP_NAME}" / ConfigConstants.CONFIG_FILENAME,
+ # 3. System configuration directory
+ _get_system_config_dir() / ConfigConstants.CONFIG_FILENAME
+ ]
+
+ # Return first existing file, if none exist return current directory
+ for path in search_paths:
+ if path.exists():
+ return path
+
+ return search_paths[0]
+
+def get_default_config() -> Dict[str, Any]:
+ """Get default configuration (empty configuration, avoid hardcoded examples)"""
+ return {
+ "mcpServers": {},
+ "version": ConfigConstants.DEFAULT_VERSION,
+ "description": "MCPStore configuration file",
+ "created_by": "MCPStore CLI",
+ "created_at": None # Will be set when saving
+ }
+
+def get_example_services() -> Dict[str, Dict[str, Any]]:
+ """Get example service configurations (for documentation and help)"""
+ return {
+ "remote-http-service": {
+ "url": "https://example.com/mcp",
+ "transport": "streamable-http",
+ "headers": {},
+ "description": "Example remote HTTP MCP service"
+ },
+ "local-command-service": {
+ "command": "python",
+ "args": ["-m", "your_mcp_server"],
+ "env": {},
+ "working_dir": ".",
+ "description": "Example local command MCP service"
+ },
+ "npm-package-service": {
+ "command": "npx",
+ "args": ["-y", "some-mcp-package"],
+ "description": "Example NPM package MCP service"
+ }
+ }
+
+def load_config(path: Optional[str] = None) -> Dict[str, Any]:
+ """Load configuration file"""
+ if path:
+ config_path = Path(path)
+ else:
+ config_path = get_default_config_path()
+
+ if not config_path.exists():
+ typer.echo(f"[WARNING] Configuration file not found: {config_path}")
+ return {}
+
+ try:
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+ typer.echo(f" Configuration loaded from: {config_path}")
+ return config
+ except json.JSONDecodeError as e:
+ typer.echo(f" Invalid JSON in config file: {e}")
+ return {}
+ except Exception as e:
+ typer.echo(f" Failed to load config: {e}")
+ return {}
+
+def save_config(config: Dict[str, Any], path: Optional[str] = None) -> bool:
+ """Save configuration file"""
+ if path:
+ config_path = Path(path)
+ else:
+ config_path = get_default_config_path()
+
+ try:
+ # Ensure directory exists
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(config_path, 'w', encoding='utf-8') as f:
+ json.dump(config, f, indent=2, ensure_ascii=False)
+
+ typer.echo(f" Configuration saved to: {config_path}")
+ return True
+ except Exception as e:
+ typer.echo(f" Failed to save config: {e}")
+ return False
+
+def _detect_service_type(server_config: Dict[str, Any]) -> str:
+ """Detect service type"""
+ if "url" in server_config:
+ return "url"
+ elif "command" in server_config:
+ return "command"
+ else:
+ return "unknown"
+
+def _validate_service_config(name: str, server_config: Dict[str, Any]) -> List[str]:
+ """Validate single service configuration"""
+ errors = []
+
+ if not isinstance(server_config, dict):
+ errors.append(f"Service '{name}' config must be an object")
+ return errors
+
+ service_type = _detect_service_type(server_config)
+
+ if service_type == "unknown":
+ errors.append(f"Service '{name}' must have either 'url' or 'command' field")
+ return errors
+
+ # Validate required fields
+ required_fields = ConfigConstants.REQUIRED_FIELDS.get(service_type, [])
+ for field in required_fields:
+ if field not in server_config:
+ errors.append(f"Service '{name}' missing required field '{field}' for {service_type} type")
+
+ # Validate field types
+ type_validations = {
+ "args": (list, "must be a list"),
+ "env": (dict, "must be an object"),
+ "headers": (dict, "must be an object"),
+ "transport": (str, "must be a string"),
+ "url": (str, "must be a string"),
+ "command": (str, "must be a string"),
+ "working_dir": (str, "must be a string"),
+ }
+
+ for field, (expected_type, error_msg) in type_validations.items():
+ if field in server_config and not isinstance(server_config[field], expected_type):
+ errors.append(f"Service '{name}' field '{field}' {error_msg}")
+
+ # Validate transport value
+ if "transport" in server_config:
+ transport = server_config["transport"]
+ if transport not in ConfigConstants.SUPPORTED_TRANSPORTS:
+ errors.append(f"Service '{name}' unsupported transport '{transport}'. Supported: {', '.join(ConfigConstants.SUPPORTED_TRANSPORTS)}")
+
+ return errors
+
+def validate_config(config: Dict[str, Any]) -> bool:
+ """Validate configuration file format"""
+ errors = []
+
+ # Check root-level required fields
+ if "mcpServers" not in config:
+ errors.append("Missing 'mcpServers' field")
+ typer.echo(" Configuration validation failed:")
+ for error in errors:
+ typer.echo(f" • {error}")
+ return False
+
+ servers = config["mcpServers"]
+ if not isinstance(servers, dict):
+ errors.append("'mcpServers' must be an object")
+ else:
+ # Validate each service configuration
+ for name, server_config in servers.items():
+ service_errors = _validate_service_config(name, server_config)
+ errors.extend(service_errors)
+
+ # Output results
+ if errors:
+ typer.echo(" Configuration validation failed:")
+ for error in errors:
+ typer.echo(f" • {error}")
+ return False
+ else:
+ typer.echo(" Configuration is valid")
+ return True
+
+def _format_service_info(name: str, server_config: Dict[str, Any]) -> None:
+ """Format and display single service information"""
+ service_type = _detect_service_type(server_config)
+ desc = server_config.get("description", "No description")
+
+ # Service type prefix
+ type_prefixes = {
+ "url": "[URL]",
+ "command": "[CMD]",
+ "unknown": "[?]"
+ }
+
+ prefix = type_prefixes.get(service_type, "[?]")
+ typer.echo(f"\n {prefix} {name} ({service_type} service)")
+ typer.echo(f" Description: {desc}")
+
+ # Show different information based on service type
+ if service_type == "url":
+ url = server_config.get("url", "")
+ transport = server_config.get("transport", "streamable-http")
+ typer.echo(f" URL: {url}")
+ typer.echo(f" Transport: {transport}")
+
+ headers = server_config.get("headers", {})
+ if headers:
+ typer.echo(f" Headers:")
+ for key, value in headers.items():
+ typer.echo(f" {key}: {value}")
+
+ elif service_type == "command":
+ command = server_config.get("command", "")
+ args = server_config.get("args", [])
+ working_dir = server_config.get("working_dir", "")
+
+ typer.echo(f" Command: {command}")
+ if args:
+ typer.echo(f" Args: {' '.join(args)}")
+ if working_dir:
+ typer.echo(f" Working Dir: {working_dir}")
+
+ # Show environment variables
+ env = server_config.get("env", {})
+ if env:
+ typer.echo(f" Environment:")
+ for key, value in env.items():
+ typer.echo(f" {key}={value}")
+
+def show_config(path: Optional[str] = None):
+ """Display configuration file content"""
+ config = load_config(path)
+
+ if not config:
+ typer.echo("No configuration found")
+ return
+
+ separator = ConfigConstants.SEPARATOR_CHAR * ConfigConstants.SEPARATOR_LENGTH
+
+ typer.echo("\n[CONFIG] Current Configuration:")
+ typer.echo(separator)
+
+ # Show basic information
+ version = config.get("version", "unknown")
+ description = config.get("description", "No description")
+ created_by = config.get("created_by", "Unknown")
+
+ typer.echo(f"Version: {version}")
+ typer.echo(f"Description: {description}")
+ typer.echo(f"Created by: {created_by}")
+
+ # Show service list
+ servers = config.get("mcpServers", {})
+ typer.echo(f"\nMCP Services ({len(servers)} configured):")
+
+ if not servers:
+ typer.echo(" No services configured")
+ typer.echo("\n[TIP] Use 'mcpstore config add-example' to add example services")
+ else:
+ for name, server_config in servers.items():
+ _format_service_info(name, server_config)
+
+def init_config(path: Optional[str] = None, force: bool = False, with_examples: bool = False):
+ """Initialize configuration file"""
+ if path:
+ config_path = Path(path)
+ else:
+ config_path = get_default_config_path()
+
+ if config_path.exists() and not force:
+ typer.echo(f"[WARNING] Configuration file already exists: {config_path}")
+ typer.echo("Use --force to overwrite")
+ return
+
+ # Get basic configuration
+ config = get_default_config()
+
+ # Add creation time
+ from datetime import datetime
+ config["created_at"] = datetime.now().isoformat()
+
+ # Add example services if needed
+ if with_examples:
+ config["mcpServers"] = get_example_services()
+ typer.echo("[CONFIG] Including example services in configuration")
+
+ if save_config(config, str(config_path)):
+ typer.echo("[SUCCESS] Configuration initialized successfully!")
+ typer.echo(f" Location: {config_path}")
+
+ if with_examples:
+ typer.echo("\n[TIP] Example services have been added. Edit the file to customize them.")
+ else:
+ typer.echo("\n[TIP] Empty configuration created. Add services using 'mcpstore config add' or edit the file manually.")
+
+def add_example_services(path: Optional[str] = None):
+ """Add example services to existing configuration"""
+ config = load_config(path)
+ if not config:
+ typer.echo(" No configuration found. Use 'init' first.")
+ return
+
+ examples = get_example_services()
+ servers = config.get("mcpServers", {})
+
+ added_count = 0
+ for name, service_config in examples.items():
+ if name not in servers:
+ servers[name] = service_config
+ added_count += 1
+ typer.echo(f" Added example service: {name}")
+ else:
+ typer.echo(f"[WARNING] Service '{name}' already exists, skipping")
+
+ if added_count > 0:
+ config["mcpServers"] = servers
+ if save_config(config, path):
+ typer.echo(f"\n[SUCCESS] Added {added_count} example services!")
+ else:
+ typer.echo("\n[INFO] No new services were added.")
+
+def handle_config(action: str, path: Optional[str] = None, **kwargs):
+ """Handle configuration command (improved version)"""
+ actions = {
+ "show": lambda: show_config(path),
+ "validate": lambda: _handle_validate(path),
+ "init": lambda: _handle_init(path, **kwargs),
+ "add-examples": lambda: add_example_services(path),
+ "path": lambda: _show_config_path(path),
+ }
+
+ if action in actions:
+ actions[action]()
+ else:
+ typer.echo(f" Unknown action: {action}")
+ typer.echo(f"Available actions: {', '.join(actions.keys())}")
+
+def _handle_validate(path: Optional[str] = None):
+ """Handle validation command"""
+ config = load_config(path)
+ if config:
+ validate_config(config)
+ else:
+ typer.echo(" No configuration to validate")
+
+def _handle_init(path: Optional[str] = None, **kwargs):
+ """Handle initialization command"""
+ force = kwargs.get('force', False)
+ with_examples = kwargs.get('with_examples', False)
+
+ # If file exists and no force flag, ask user
+ config_path = Path(path) if path else get_default_config_path()
+ if config_path.exists() and not force:
+ force = typer.confirm("Configuration file exists. Overwrite?")
+
+ init_config(path, force, with_examples)
+
+def _show_config_path(path: Optional[str] = None):
+ """显示配置文件路径"""
+ if path:
+ config_path = Path(path)
+ else:
+ config_path = get_default_config_path()
+
+ typer.echo(f" Configuration file path: {config_path}")
+ typer.echo(f"[INFO] Exists: {'Yes' if config_path.exists() else 'No'}")
+
+ if config_path.exists():
+ stat = config_path.stat()
+ typer.echo(f"[INFO] Size: {stat.st_size} bytes")
+ from datetime import datetime
+ modified_time = datetime.fromtimestamp(stat.st_mtime)
+ typer.echo(f"[INFO] Last modified: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
+
diff --git a/src/mcpstore/cli/main.py b/src/mcpstore/cli/main.py
index c8e96557..7ef37512 100644
--- a/src/mcpstore/cli/main.py
+++ b/src/mcpstore/cli/main.py
@@ -1,57 +1,197 @@
-import uvicorn
-import typer
-import asyncio
+#!/usr/bin/env python3
+"""
+MCPStore CLI - Command Line Interface for MCPStore
+"""
import sys
+from typing import Optional
+
+import typer
+import uvicorn
from typing_extensions import Annotated
-from mcpstore.scripts.app import app # 导入 app 对象
-import logging
-# 导入独立运行模式
+# Create main CLI application
+app = typer.Typer(
+ name="mcpstore",
+ help="MCPStore - A composable, ready-to-use MCP toolkit for agents and rapid integration.",
+ no_args_is_help=True,
+ rich_markup_mode="rich"
+)
+
+@app.callback()
+def callback():
+ """
+ MCPStore Command Line Interface
+
+ A powerful toolkit for managing MCP (Model Context Protocol) services.
+ """
+ pass
+
+@app.command("run")
+def run_command(
+ service: Annotated[str, typer.Argument(help="Service to run (api, test, etc.)")],
+ host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0",
+ port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 18200,
+ reload: Annotated[bool, typer.Option("--reload", "-r", help="Enable auto-reload")] = False,
+ log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "info",
+ prefix: Annotated[str, typer.Option("--prefix", help="URL prefix (e.g., /api/v1)")] = "",
+):
+ """
+ Run MCPStore services
+ Available services:
+ - api: Start the MCPStore API server
-# Set up logging for the CLI itself
-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-cli_logger = logging.getLogger("cli_main")
+ Examples:
+ # Basic startup (no prefix)
+ mcpstore run api
-app_cli = typer.Typer(no_args_is_help=True)
+ # Use URL prefix
+ mcpstore run api --prefix /api/v1
+ # Access: http://localhost:18200/api/v1/for_store/list_services
-@app_cli.callback()
-def callback():
+ # Development mode + prefix
+ mcpstore run api --reload --prefix /api
+ """
+ if service == "api":
+ run_api(host=host, port=port, reload=reload, log_level=log_level, url_prefix=prefix)
+ else:
+ typer.echo(f" Unknown service: {service}")
+ typer.echo("Available services: api")
+ raise typer.Exit(1)
+
+def run_api(host: str, port: int, reload: bool, log_level: str, url_prefix: str):
+ """Start MCPStore API service"""
+ try:
+ typer.echo("[START] Starting MCPStore API Server...")
+ typer.echo(f" Host: {host}:{port}")
+
+ if url_prefix:
+ typer.echo(f" URL Prefix: {url_prefix}")
+ base_url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
+ typer.echo(f" Example: {base_url}{url_prefix}/for_store/list_services")
+ else:
+ base_url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
+ typer.echo(f" Example: {base_url}/for_store/list_services")
+
+ if reload:
+ typer.echo(" Mode: Development (auto-reload enabled)")
+ typer.echo(" Press Ctrl+C to stop")
+ typer.echo()
+
+ # Pass URL prefix configuration at startup (no environment variables used)
+ if url_prefix:
+ typer.echo(f" Using URL prefix (applied at app level): {url_prefix}")
+
+ # Start API service
+ uvicorn.run(
+ "mcpstore.scripts.app:app",
+ host=host,
+ port=port,
+ reload=reload,
+ log_level=log_level
+ )
+ except KeyboardInterrupt:
+ typer.echo("\n[STOPPED] Server stopped by user")
+ except Exception as e:
+ typer.echo(f" Failed to start server: {e}")
+ raise typer.Exit(1)
+
+@app.command("version")
+def version():
+ """Show version information"""
+ try:
+ from mcpstore import __version__
+ version_str = __version__
+ except ImportError:
+ version_str = "0.2.0"
+
+ typer.echo(f"MCPStore version: {version_str}")
+
+@app.command("test")
+def test_command(
+ suite: Annotated[
+ Optional[str],
+ typer.Argument(help="Test suite to run")
+ ] = "all",
+ host: Annotated[str, typer.Option("--host", help="API server host")] = "localhost",
+ port: Annotated[int, typer.Option("--port", help="API server port")] = 18611,
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False,
+ performance: Annotated[bool, typer.Option("--performance", "-p", help="Include performance tests")] = False,
+ max_concurrent: Annotated[int, typer.Option("--max-concurrent", help="Max concurrent requests for performance tests")] = 10,
+):
"""
- MCP Store Command Line Interface.
+ Run MCPStore tests
+
+ Available test suites:
+ - health: Quick health check
+ - smoke: Smoke tests (basic functionality)
+ - api: Basic API tests
+ - core: Core functionality tests
+ - advanced: Advanced API tests
+ - performance: Performance and load tests
+ - comprehensive: All tests including performance
+ - all: Basic tests (default)
"""
- cli_logger.info("【第4步】Typer 回调函数已执行,准备分发子命令。")
- pass
+ try:
+ import asyncio
+ from mcpstore.cli.test_runner import run_tests
+
+ # For comprehensive testing, use special handling
+ if suite == "comprehensive":
+ from mcpstore.cli.comprehensive_test import run_comprehensive_tests
+ base_url = f"http://{host}:{port}"
+ success = asyncio.run(run_comprehensive_tests(
+ base_url=base_url,
+ include_performance=performance,
+ max_concurrent=max_concurrent,
+ verbose=verbose
+ ))
+ else:
+ success = asyncio.run(run_tests(suite=suite, host=host, port=port, verbose=verbose))
-@app_cli.command()
-def api(
- host: Annotated[
- str, typer.Option(help="The host to bind to.")
- ] = "0.0.0.0",
- port: Annotated[
- int, typer.Option(help="The port to bind to.")
- ] = 18611,
- reload: Annotated[
- bool,
- typer.Option(
- help="Enable auto-reloading.",
- ),
- ] = False,
+ if not success:
+ raise typer.Exit(1)
+ except ImportError as e:
+ typer.echo(f" Test runner not available: {e}")
+ raise typer.Exit(1)
+ except Exception as e:
+ typer.echo(f" Test failed: {e}")
+ raise typer.Exit(1)
+
+@app.command("config")
+def config_command(
+ action: Annotated[str, typer.Argument(help="Action: show, validate, init")],
+ path: Annotated[Optional[str], typer.Option("--path", help="Config file path")] = None,
):
- """启动 mcpstore API 服务"""
- cli_logger.info(f"【第5步】Typer 已成功匹配到 'api' 命令。")
- cli_logger.info(f" - 接收到参数 Host: {host}")
- cli_logger.info(f" - 接收到参数 Port: {port}")
- cli_logger.info(f" - 接收到参数 Reload: {reload}")
- cli_logger.info("【第6步】CLI 任务完成,准备将控制权移交给 Uvicorn。")
- uvicorn.run("mcpstore.scripts.app:app", host=host, port=port, reload=reload)
+ """
+ Manage MCPStore configuration
+
+ Actions:
+ - show: Display current configuration
+ - validate: Validate configuration file
+ - init: Initialize default configuration
+ """
+ try:
+ from mcpstore.cli.config_manager import handle_config
+ handle_config(action=action, path=path)
+ except ImportError:
+ typer.echo(" Config manager not available")
+ raise typer.Exit(1)
+ except Exception as e:
+ typer.echo(f" Config operation failed: {e}")
+ raise typer.Exit(1)
def main():
- cli_logger.info("【第3步】Typer 主应用已启动,准备解析命令行参数。")
- app_cli()
+ """CLI entry point"""
+ try:
+ app()
+ except KeyboardInterrupt:
+ typer.echo("\n[INFO] Goodbye!")
+ sys.exit(0)
+ except Exception as e:
+ typer.echo(f" CLI error: {e}")
+ sys.exit(1)
if __name__ == "__main__":
- cli_logger.info("【第1步】命令行入口 (__name__ == '__main__') 已触发。")
- cli_logger.info("【第2步】即将调用 main() 函数。")
- main()
+ main()
diff --git a/src/mcpstore/config/__init__.py b/src/mcpstore/config/__init__.py
new file mode 100644
index 00000000..c4023064
--- /dev/null
+++ b/src/mcpstore/config/__init__.py
@@ -0,0 +1,63 @@
+"""
+Configuration module
+"""
+
+# Import cache configuration classes (required)
+from .cache_config import (
+ CacheType,
+ DataSourceStrategy,
+ BaseCacheConfig,
+ MemoryConfig,
+ RedisConfig,
+ get_namespace,
+ detect_strategy,
+ create_kv_store,
+ create_kv_store_async,
+)
+# Direct import of original config module
+from .config import LoggingConfig, load_app_config
+# Import health check functionality
+from .health_check import (
+ RedisHealthCheck,
+ start_health_check
+)
+# Import error handling
+from .redis_errors import (
+ RedisConnectionFailure,
+ mask_password_in_url,
+ get_connection_info,
+ handle_redis_connection_error,
+ test_redis_connection
+)
+# Import TOML configuration management
+from .toml_config import (
+ initialize_config_system,
+ ensure_config_directory,
+ create_default_config_if_not_exists,
+ get_user_config_path,
+)
+
+__all__ = [
+ 'LoggingConfig',
+ 'load_app_config',
+ 'CacheType',
+ 'DataSourceStrategy',
+ 'BaseCacheConfig',
+ 'MemoryConfig',
+ 'RedisConfig',
+ 'get_namespace',
+ 'detect_strategy',
+ 'create_kv_store',
+ 'create_kv_store_async',
+ 'RedisHealthCheck',
+ 'start_health_check',
+ 'RedisConnectionFailure',
+ 'mask_password_in_url',
+ 'get_connection_info',
+ 'handle_redis_connection_error',
+ 'test_redis_connection',
+ 'initialize_config_system',
+ 'ensure_config_directory',
+ 'create_default_config_if_not_exists',
+ 'get_user_config_path',
+]
diff --git a/src/mcpstore/config/cache_config.py b/src/mcpstore/config/cache_config.py
new file mode 100644
index 00000000..dd88bd2a
--- /dev/null
+++ b/src/mcpstore/config/cache_config.py
@@ -0,0 +1,379 @@
+"""
+Cache configuration classes for MCPStore.
+
+This module provides type-safe configuration classes for different cache backends.
+Non-sensitive configuration is loaded from MCPStoreConfig, sensitive configuration from environment variables.
+"""
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Optional, Literal, Union
+
+from redis.asyncio import Redis
+
+
+class CacheType(Enum):
+ """Cache type enumeration."""
+ MEMORY = "memory"
+ REDIS = "redis"
+
+
+class DataSourceStrategy(Enum):
+ """
+ 数据源策略枚举
+
+ 定义了三种数据源策略,决定数据如何存储和同步:
+ - local_memory: JSON + Memory 缓存,标准本地配置
+ - local_db: JSON + Redis 缓存,本地配置 + 远程存储
+ - only_db: 仅 Redis 缓存,无本地 JSON 文件
+
+ 注意: 所有一致性数据统一通过 add_service() 写入三层缓存架构
+ """
+ LOCAL_MEMORY = "local_memory" # JSON + Memory 缓存 (标准本地配置)
+ LOCAL_DB = "local_db" # JSON + Redis 缓存 (本地配置 + 远程存储)
+ ONLY_DB = "only_db" # 仅 Redis 缓存 (无本地 JSON 文件)
+
+
+
+@dataclass
+class BaseCacheConfig:
+ """Base cache configuration class with common attributes."""
+ timeout: float = 2.0
+ retry_attempts: int = 3
+ health_check: bool = True
+
+
+
+@dataclass
+class MemoryConfig(BaseCacheConfig):
+ """Memory cache configuration."""
+ max_size: Optional[int] = None
+ cleanup_interval: int = 300
+ cache_type: Literal[CacheType.MEMORY] = CacheType.MEMORY
+
+
+
+@dataclass
+class RedisConfig(BaseCacheConfig):
+ """Redis cache configuration with validation."""
+
+ # Basic connection configuration
+ url: Optional[str] = None
+ host: Optional[str] = None
+ port: Optional[int] = None
+ db: Optional[int] = None
+ password: Optional[str] = None
+ namespace: Optional[str] = None
+
+ # Redis client object (Method 1: pass directly)
+ client: Optional[Redis] = None
+
+ # Connection pool configuration
+ max_connections: int = 50
+ retry_on_timeout: bool = True
+ socket_keepalive: bool = True
+ socket_connect_timeout: float = 5.0
+ socket_timeout: float = 5.0
+ health_check_interval: int = 30
+
+ # Allow partial configuration for testing/default scenarios
+ allow_partial: bool = False
+
+ cache_type: Literal[CacheType.REDIS] = CacheType.REDIS
+
+ def __post_init__(self):
+ """Validate configuration parameters."""
+ # If no client provided, must provide URL or host (unless partial allowed)
+ if self.client is None and not self.allow_partial:
+ if not self.url and not self.host:
+ raise ValueError(
+ "Redis configuration requires either 'client', 'url', or 'host'. "
+ "Example: RedisConfig(url='redis://localhost:6379/0') or "
+ "RedisConfig(host='localhost', port=6379)"
+ )
+
+ # Validate timeout parameters
+ if self.timeout <= 0:
+ raise ValueError(
+ f"timeout must be positive, got: {self.timeout}. "
+ "Example: RedisConfig(url='redis://localhost:6379/0', timeout=5.0)"
+ )
+
+ if self.socket_timeout <= 0:
+ raise ValueError(
+ f"socket_timeout must be positive, got: {self.socket_timeout}. "
+ "Example: RedisConfig(url='redis://localhost:6379/0', socket_timeout=5.0)"
+ )
+
+ # Validate connection pool parameters
+ if self.max_connections <= 0:
+ raise ValueError(
+ f"max_connections must be positive, got: {self.max_connections}. "
+ "Example: RedisConfig(url='redis://localhost:6379/0', max_connections=50)"
+ )
+
+
+def get_namespace(config: RedisConfig) -> str:
+ """
+ Get the namespace for Redis configuration.
+
+ Args:
+ config: Redis configuration object
+
+ Returns:
+ Namespace string - user-provided namespace if set, otherwise default "mcpstore"
+
+ Examples:
+ >>> config = RedisConfig(url="redis://localhost:6379/0")
+ >>> get_namespace(config)
+ 'mcpstore'
+
+ >>> config = RedisConfig(url="redis://localhost:6379/0", namespace="myapp")
+ >>> get_namespace(config)
+ 'myapp'
+ """
+ if config.namespace:
+ return config.namespace
+ return "mcpstore"
+
+
+def detect_strategy(
+ cache_config: Optional[BaseCacheConfig],
+ json_path: Optional[str],
+ *,
+ only_db: bool = False,
+) -> DataSourceStrategy:
+ """
+ 根据配置自动检测数据源策略
+
+ Args:
+ cache_config: 缓存配置对象 (MemoryConfig 或 RedisConfig)
+ json_path: JSON 文件路径 (可选)
+
+ Returns:
+ DataSourceStrategy 枚举值
+
+ 策略检测逻辑:
+ - JSON + Memory → LOCAL_MEMORY (标准本地配置)
+ - JSON + Redis → LOCAL_DB (本地配置 + 远程存储)
+ - 无 JSON + 任意 → ONLY_DB (仅远程存储)
+
+ 注意: 所有一致性数据统一通过 add_service() 写入三层缓存架构
+
+ Examples:
+ >>> detect_strategy(MemoryConfig(), "mcp.json")
+ DataSourceStrategy.LOCAL_MEMORY
+
+ >>> detect_strategy(RedisConfig(url="redis://localhost:6379/0"), "mcp.json")
+ DataSourceStrategy.LOCAL_DB
+
+ >>> detect_strategy(RedisConfig(url="redis://localhost:6379/0"), None)
+ DataSourceStrategy.ONLY_DB
+ """
+ if only_db:
+ return DataSourceStrategy.ONLY_DB
+
+ has_json = json_path is not None
+ is_memory = isinstance(cache_config, MemoryConfig)
+
+ if not has_json:
+ # 在新语义下,只要未显式启用 only_db,就认为仍需同步本地配置
+ # 此时缺少 json_path 说明调用方未提供,自行降级为默认路径
+ has_json = True
+
+ if is_memory:
+ return DataSourceStrategy.LOCAL_MEMORY
+ else:
+ return DataSourceStrategy.LOCAL_DB
+
+
+async def create_kv_store_async(cache_config: Union[MemoryConfig, RedisConfig], test_connection: bool = True):
+ """
+ Async version of create_kv_store with connection testing.
+
+ This async function creates a key-value store and optionally tests the connection.
+ Use this when you need to verify the connection immediately in an async context.
+
+ Args:
+ cache_config: Cache configuration object (MemoryConfig or RedisConfig)
+ test_connection: If True, test Redis connection immediately (default: True)
+
+ Returns:
+ MemoryStore or RedisStore instance
+
+ Raises:
+ ValueError: If cache_config type is not supported
+ RedisConnectionFailure: If Redis connection fails (with detailed context)
+
+ Examples:
+ >>> config = RedisConfig(url="redis://localhost:6379/0")
+ >>> store = await create_kv_store_async(config, test_connection=True)
+ """
+ from key_value.aio.stores.memory import MemoryStore
+ from key_value.aio.stores.redis import RedisStore
+ from mcpstore.config.redis_errors import (
+ handle_redis_connection_error,
+ test_redis_connection,
+ RedisConnectionFailure
+ )
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ if isinstance(cache_config, MemoryConfig):
+ logger.debug(f"Creating MemoryStore with max_size={cache_config.max_size}, cleanup_interval={cache_config.cleanup_interval}s")
+ return MemoryStore()
+
+ if isinstance(cache_config, RedisConfig):
+ namespace = get_namespace(cache_config)
+
+ try:
+ # Test connection first if requested
+ if test_connection:
+ await test_redis_connection(cache_config)
+
+ # Create store after successful connection test
+ if cache_config.client:
+ logger.debug(f"Creating RedisStore with user-provided client, namespace={namespace}")
+ store = RedisStore(
+ client=cache_config.client,
+ default_collection=namespace
+ )
+ elif cache_config.url:
+ logger.debug(f"Creating RedisStore with URL, namespace={namespace}")
+ store = RedisStore(
+ url=cache_config.url,
+ default_collection=namespace
+ )
+ else:
+ logger.debug(f"Creating RedisStore with parameters: host={cache_config.host}, port={cache_config.port or 6379}, db={cache_config.db or 0}, namespace={namespace}")
+ store = RedisStore(
+ host=cache_config.host,
+ port=cache_config.port or 6379,
+ db=cache_config.db or 0,
+ password=cache_config.password,
+ default_collection=namespace
+ )
+
+ return store
+
+ except RedisConnectionFailure:
+ # Re-raise RedisConnectionFailure as-is (already formatted)
+ raise
+ except Exception as e:
+ # Handle other exceptions
+ raise handle_redis_connection_error(e, cache_config)
+
+ raise ValueError(f"Unsupported cache config type: {type(cache_config)}")
+
+
+def create_kv_store(cache_config: Union[MemoryConfig, RedisConfig], test_connection: bool = False):
+ """
+ Create a py-key-value store based on cache configuration.
+
+ This factory function creates the appropriate key-value store instance
+ based on the provided cache configuration. It supports:
+ - MemoryStore for MemoryConfig
+ - RedisStore for RedisConfig (with three initialization methods)
+
+ For Redis connections, this function uses a fail-fast strategy when test_connection=True:
+ - Connection errors are caught immediately during initialization
+ - Detailed error messages with masked passwords are provided
+ - Troubleshooting steps are included in error messages
+ - Authentication and network errors are distinguished
+
+ Note: py-key-value's RedisStore uses lazy connection (connects on first use).
+ Set test_connection=True to verify the connection immediately.
+
+ Args:
+ cache_config: Cache configuration object (MemoryConfig or RedisConfig)
+ test_connection: If True, test Redis connection immediately (default: False)
+
+ Returns:
+ MemoryStore or RedisStore instance
+
+ Raises:
+ ValueError: If cache_config type is not supported
+ RedisConnectionFailure: If Redis connection fails (with detailed context)
+
+ Examples:
+ >>> # Create memory store
+ >>> config = MemoryConfig()
+ >>> store = create_kv_store(config)
+
+ >>> # Create Redis store with URL
+ >>> config = RedisConfig(url="redis://localhost:6379/0")
+ >>> store = create_kv_store(config)
+
+ >>> # Create Redis store with connection test
+ >>> config = RedisConfig(url="redis://localhost:6379/0")
+ >>> store = create_kv_store(config, test_connection=True)
+
+ >>> # Create Redis store with existing client
+ >>> from redis.asyncio import Redis
+ >>> client = Redis(host="localhost", port=6379)
+ >>> config = RedisConfig(client=client)
+ >>> store = create_kv_store(config)
+
+ >>> # Create Redis store with parameters
+ >>> config = RedisConfig(host="localhost", port=6379, db=0)
+ >>> store = create_kv_store(config)
+ """
+ import logging
+ from key_value.aio.stores.memory import MemoryStore
+ from key_value.aio.stores.redis import RedisStore
+ from mcpstore.config.redis_errors import handle_redis_connection_error
+
+ logger = logging.getLogger(__name__)
+
+ if isinstance(cache_config, MemoryConfig):
+ # Create MemoryStore for memory cache configuration
+ logger.debug(f"Creating MemoryStore with max_size={cache_config.max_size}, cleanup_interval={cache_config.cleanup_interval}s")
+ return MemoryStore()
+
+ if isinstance(cache_config, RedisConfig):
+ # Get namespace for Redis (use default if not set)
+ namespace = get_namespace(cache_config)
+
+ try:
+ # Method 1: Use existing Redis client object
+ if cache_config.client:
+ logger.debug(f"Creating RedisStore with user-provided client, namespace={namespace}")
+ store = RedisStore(
+ client=cache_config.client,
+ default_collection=namespace
+ )
+
+ # Method 2: Use URL string
+ elif cache_config.url:
+ logger.debug(f"Creating RedisStore with URL, namespace={namespace}")
+ store = RedisStore(
+ url=cache_config.url,
+ default_collection=namespace
+ )
+
+ # Method 3: Use connection parameters
+ else:
+ logger.debug(f"Creating RedisStore with parameters: host={cache_config.host}, port={cache_config.port or 6379}, db={cache_config.db or 0}, namespace={namespace}")
+ store = RedisStore(
+ host=cache_config.host,
+ port=cache_config.port or 6379,
+ db=cache_config.db or 0,
+ password=cache_config.password,
+ default_collection=namespace
+ )
+
+ # Test connection if requested (fail-fast)
+ # Note: This is a synchronous function, so we can't await.
+ # The test_connection parameter is mainly for documentation.
+ # Actual connection testing happens on first use of the store.
+ if test_connection:
+ logger.debug("test_connection=True, but connection test deferred to first use (py-key-value uses lazy connection)")
+
+ return store
+
+ except Exception as e:
+ # Handle Redis connection errors with detailed context
+ raise handle_redis_connection_error(e, cache_config)
+
+ raise ValueError(f"Unsupported cache config type: {type(cache_config)}")
diff --git a/src/mcpstore/config/cache_environment.py b/src/mcpstore/config/cache_environment.py
new file mode 100644
index 00000000..3c9fc7bf
--- /dev/null
+++ b/src/mcpstore/config/cache_environment.py
@@ -0,0 +1,31 @@
+"""
+Cache environment variables management.
+
+This module handles reading sensitive cache configuration from environment variables,
+ensuring sensitive data never gets stored in TOML files or KV storage.
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def get_sensitive_redis_config() -> dict:
+ """
+ Get all sensitive Redis configuration from environment variables.
+
+ Returns:
+ Dictionary containing sensitive configuration
+ """
+ config = {}
+ return config
+
+
+def get_cache_type_from_env() -> str:
+ """
+ Get cache type from environment variables.
+
+ Returns:
+ Cache type ('memory' or 'redis'), defaults to 'memory'
+ """
+ return "memory"
diff --git a/src/mcpstore/config/config.py b/src/mcpstore/config/config.py
index 054918b5..1e7aee96 100644
--- a/src/mcpstore/config/config.py
+++ b/src/mcpstore/config/config.py
@@ -1,65 +1,219 @@
-import os
-import sys
-
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-import logging
-from typing import Dict, Any
-
-logger = logging.getLogger(__name__)
-
-# --- Configuration Constants (default values) ---
-HEARTBEAT_INTERVAL_SECONDS = 60
-HEARTBEAT_TIMEOUT_SECONDS = 180
-HTTP_TIMEOUT_SECONDS = 10
-RECONNECTION_INTERVAL_SECONDS = 60
-REACT_MAX_ITERATIONS = 5
-REACT_ENABLE_TRACE = False
-STREAMABLE_HTTP_ENDPOINT = "/mcp"
-
-# @dataclass
-# class LLMConfig:
-# provider: str = "openai_compatible"
-# api_key: str = ""
-# model: str = ""
-# base_url: Optional[str] = None
-
-# def load_llm_config() -> LLMConfig:
-# """从环境变量加载LLM配置(仅支持openai兼容接口)"""
-# api_key = os.environ.get("OPENAI_API_KEY", "")
-# model = os.environ.get("OPENAI_MODEL", "")
-# base_url = os.environ.get("OPENAI_BASE_URL")
-# provider = "openai_compatible"
-# if not api_key:
-# logger.warning("OPENAI_API_KEY not set in environment.")
-# if not model:
-# logger.warning("OPENAI_MODEL not set in environment.")
-# return LLMConfig(provider=provider, api_key=api_key, model=model, base_url=base_url)
-
-def _get_env_int(var: str, default: int) -> int:
- try:
- return int(os.environ.get(var, default))
- except Exception:
- logger.warning(f"环境变量{var}格式错误,使用默认值{default}")
- return default
-
-def _get_env_bool(var: str, default: bool) -> bool:
- val = os.environ.get(var)
- if val is None:
- return default
- return val.lower() in ("1", "true", "yes", "on")
-
-def load_app_config() -> Dict[str, Any]:
- """从环境变量加载全局配置"""
- config_data = {
- "heartbeat_interval": _get_env_int("HEARTBEAT_INTERVAL_SECONDS", HEARTBEAT_INTERVAL_SECONDS),
- "heartbeat_timeout": _get_env_int("HEARTBEAT_TIMEOUT_SECONDS", HEARTBEAT_TIMEOUT_SECONDS),
- "http_timeout": _get_env_int("HTTP_TIMEOUT_SECONDS", HTTP_TIMEOUT_SECONDS),
- "reconnection_interval": _get_env_int("RECONNECTION_INTERVAL_SECONDS", RECONNECTION_INTERVAL_SECONDS),
- "react_max_iterations": _get_env_int("REACT_MAX_ITERATIONS", REACT_MAX_ITERATIONS),
- "react_enable_trace": _get_env_bool("REACT_ENABLE_TRACE", REACT_ENABLE_TRACE),
- "streamable_http_endpoint": os.environ.get("STREAMABLE_HTTP_ENDPOINT", STREAMABLE_HTTP_ENDPOINT),
- }
- # 加载LLM配置
- # config_data["llm_config"] = load_llm_config()
- # logger.info(f"Loaded configuration from environment: {config_data}")
- return config_data
+"""
+Optimized configuration module
+Remove sys.path operations to improve import performance
+"""
+import logging
+from typing import Dict, Any, Union
+
+from .config_defaults import StandaloneConfigDefaults
+from .toml_config import get_standalone_config_with_defaults
+
+# Remove sys.path.append() operations to improve import performance
+# If you need to import other modules, please use relative imports or correct package structure
+
+logger = logging.getLogger(__name__)
+
+_standalone_defaults = StandaloneConfigDefaults()
+
+class LoggingConfig:
+ """Logging configuration manager"""
+
+ _debug_enabled = False
+ _configured = False
+ _current_level: int = logging.WARNING
+
+ @classmethod
+ def setup_logging(cls, debug: Union[bool, str, int] = False, force_reconfigure: bool = False):
+ """
+ Setup logging configuration.
+
+ Args:
+ debug: Backward-compatible log control. Supports:
+ - True -> DEBUG
+ - False -> WARNING (was ERROR before; now more practical)
+ - "DEBUG"/"INFO"/"WARNING"/"ERROR"/"CRITICAL" -> exact level
+ - int -> logging level constant
+ force_reconfigure: Whether to force reconfiguration
+ """
+ def _to_level(v: Union[bool, str, int]) -> int:
+ if isinstance(v, bool):
+ # False means fully mute logs by setting an OFF-level above CRITICAL
+ return logging.DEBUG if v else (logging.CRITICAL + 50)
+ if isinstance(v, int):
+ return v
+ if isinstance(v, str):
+ m = v.strip().upper()
+ return {
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "WARNING": logging.WARNING,
+ "ERROR": logging.ERROR,
+ "CRITICAL": logging.CRITICAL,
+ }.get(m, logging.WARNING)
+ return logging.WARNING
+
+ level = _to_level(debug)
+
+ if cls._configured and not force_reconfigure:
+ # Only update levels if changed
+ if level != cls._current_level:
+ cls._set_log_level(level)
+ return
+
+ # Configure log format
+ if level <= logging.DEBUG:
+ log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ else:
+ log_format = '%(levelname)s - %(message)s'
+
+ # Get root logger
+ root_logger = logging.getLogger()
+
+ # Clear existing handlers
+ for handler in root_logger.handlers[:]:
+ root_logger.removeHandler(handler)
+
+ # Create new handler
+ handler = logging.StreamHandler()
+ formatter = logging.Formatter(log_format)
+ handler.setFormatter(formatter)
+
+ # Set log level
+ root_logger.setLevel(level)
+ handler.setLevel(level)
+
+ # Add handler
+ root_logger.addHandler(handler)
+
+ # Set specific module log levels
+ cls._configure_module_loggers(level)
+
+ cls._debug_enabled = (level <= logging.DEBUG)
+ cls._current_level = level
+ cls._configured = True
+
+ @classmethod
+ def _set_log_level(cls, level_or_flag: Union[bool, str, int]):
+ """Set log level dynamically without reconfiguring handlers."""
+ # Normalize
+ if isinstance(level_or_flag, bool):
+ # False means fully mute logs by setting an OFF-level above CRITICAL
+ level = logging.DEBUG if level_or_flag else (logging.CRITICAL + 50)
+ elif isinstance(level_or_flag, int):
+ level = level_or_flag
+ else:
+ m = str(level_or_flag).strip().upper()
+ level = {
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "WARNING": logging.WARNING,
+ "ERROR": logging.ERROR,
+ "CRITICAL": logging.CRITICAL,
+ }.get(m, logging.WARNING)
+
+ # Update root logger level
+ root_logger = logging.getLogger()
+ root_logger.setLevel(level)
+
+ # Update all handler levels
+ for handler in root_logger.handlers:
+ handler.setLevel(level)
+
+ # Update specific module log levels
+ cls._configure_module_loggers(level)
+
+ cls._debug_enabled = (level <= logging.DEBUG)
+ cls._current_level = level
+
+ @classmethod
+ def _configure_module_loggers(cls, level: int):
+ """Configure specific module loggers with a unified level."""
+ mcpstore_loggers = [
+ 'mcpstore',
+ 'mcpstore.core',
+ 'mcpstore.core.store',
+ 'mcpstore.core.context',
+ 'mcpstore.core.orchestrator',
+ 'mcpstore.core.registry',
+ 'mcpstore.core.store.client_manager',
+ 'mcpstore.core.agents.session_manager',
+ 'mcpstore.core.tool_resolver',
+ 'mcpstore.plugins.json_mcp',
+ 'mcpstore.adapters.langchain_adapter'
+ ]
+ for logger_name in mcpstore_loggers:
+ module_logger = logging.getLogger(logger_name)
+ module_logger.setLevel(level)
+
+ @classmethod
+ def is_debug_enabled(cls) -> bool:
+ """Check if debug mode is enabled"""
+ return cls._debug_enabled
+
+ @classmethod
+ def enable_debug(cls):
+ """Enable debug mode"""
+ cls.setup_logging(debug=True, force_reconfigure=True)
+ # Reduce noise from third-party loggers
+ import logging as _logging
+ for _name in ("asyncio", "watchfiles", "uvicorn"):
+ try:
+ _logging.getLogger(_name).setLevel(_logging.WARNING)
+ except Exception:
+ pass
+
+ @classmethod
+ def disable_debug(cls):
+ """Disable debug mode"""
+ cls.setup_logging(debug=False, force_reconfigure=True)
+ import logging as _logging
+ for _name in ("asyncio", "watchfiles", "uvicorn"):
+ try:
+ _logging.getLogger(_name).setLevel(_logging.WARNING)
+ except Exception:
+ pass
+
+# --- Configuration Constants (default values) ---
+# Core monitoring configuration
+HEARTBEAT_INTERVAL_SECONDS = int(_standalone_defaults.heartbeat_interval_seconds) # Heartbeat check interval (seconds)
+HTTP_TIMEOUT_SECONDS = int(_standalone_defaults.http_timeout_seconds) # HTTP request timeout (seconds)
+RECONNECTION_INTERVAL_SECONDS = int(_standalone_defaults.reconnection_interval_seconds) # Reconnection attempt interval (seconds)
+
+# HTTP endpoint configuration
+STREAMABLE_HTTP_ENDPOINT = "/mcp" # Streamable HTTP endpoint path
+
+def load_app_config() -> Dict[str, Any]:
+ """Load global configuration"""
+ try:
+ standalone_config = get_standalone_config_with_defaults()
+ except Exception as e:
+ logger.warning("Failed to load standalone config from MCPStoreConfig, using defaults: %s", e)
+ standalone_config = None
+
+ def _get_value(obj: Any, attr_name: str, default: Any) -> Any:
+ if obj is None:
+ return default
+ if hasattr(obj, attr_name):
+ try:
+ value = getattr(obj, attr_name)
+ return value if value is not None else default
+ except Exception:
+ return default
+ if isinstance(obj, dict):
+ value = obj.get(attr_name, default)
+ return value if value is not None else default
+ return default
+
+ config_data = {
+ # Core monitoring configuration
+ "heartbeat_interval": _get_value(standalone_config, "heartbeat_interval_seconds", HEARTBEAT_INTERVAL_SECONDS),
+ "http_timeout": _get_value(standalone_config, "http_timeout_seconds", HTTP_TIMEOUT_SECONDS),
+ "reconnection_interval": _get_value(standalone_config, "reconnection_interval_seconds", RECONNECTION_INTERVAL_SECONDS),
+
+ # HTTP endpoint configuration
+ "streamable_http_endpoint": _get_value(standalone_config, "streamable_http_endpoint", STREAMABLE_HTTP_ENDPOINT),
+ }
+ # Load LLM configuration
+ # config_data["llm_config"] = load_llm_config()
+ # logger.info(f"Loaded configuration from environment: {config_data}")
+ return config_data
diff --git a/src/mcpstore/config/config_dataclasses.py b/src/mcpstore/config/config_dataclasses.py
new file mode 100644
index 00000000..a0d43a93
--- /dev/null
+++ b/src/mcpstore/config/config_dataclasses.py
@@ -0,0 +1,67 @@
+"""
+MCPStore Configuration Dataclasses
+
+独立的数据类定义,避免循环导入依赖
+"""
+
+from dataclasses import dataclass
+
+from .config_defaults import ContentUpdateConfigDefaults, HealthCheckConfigDefaults, ServiceLifecycleConfigDefaults
+
+_content_defaults = ContentUpdateConfigDefaults()
+_health_defaults = HealthCheckConfigDefaults()
+_service_defaults = ServiceLifecycleConfigDefaults()
+
+
+@dataclass
+class ContentUpdateConfig:
+ """Content update configuration dataclass."""
+ tools_update_interval: float = _content_defaults.tools_update_interval # 5 minutes
+ resources_update_interval: float = _content_defaults.resources_update_interval # 10 minutes
+ prompts_update_interval: float = _content_defaults.prompts_update_interval # 10 minutes
+ max_concurrent_updates: int = _content_defaults.max_concurrent_updates
+ update_timeout: float = _content_defaults.update_timeout # 30 seconds
+ max_consecutive_failures: int = _content_defaults.max_consecutive_failures
+ failure_backoff_multiplier: float = _content_defaults.failure_backoff_multiplier
+
+ enable_auto_update: bool = True
+ enable_content_validation: bool = True
+
+
+@dataclass
+class ServiceLifecycleConfig:
+ """Service lifecycle configuration (single source of truth)"""
+ # State transition thresholds (failure count)
+ warning_failure_threshold: int = _health_defaults.warning_failure_threshold # First failure in HEALTHY enters WARNING
+ reconnecting_failure_threshold: int = _health_defaults.reconnecting_failure_threshold # Two consecutive failures in WARNING enter RECONNECTING
+ max_reconnect_attempts: int = _health_defaults.max_reconnect_attempts # Maximum reconnection attempts
+
+ # Reconnection backoff
+ base_reconnect_delay: float = _health_defaults.base_reconnect_delay # Base reconnection delay (seconds)
+ max_reconnect_delay: float = _health_defaults.max_reconnect_delay # Maximum reconnection delay (seconds)
+ long_retry_interval: float = _health_defaults.long_retry_interval # Long retry interval (seconds)
+
+ # Health check (period/threshold/timeout)
+ normal_heartbeat_interval: float = _health_defaults.normal_heartbeat_interval # Normal state heartbeat interval
+ warning_heartbeat_interval: float = _health_defaults.warning_heartbeat_interval # Warning state heartbeat interval
+ health_check_ping_timeout: float = _health_defaults.health_check_ping_timeout # Health check ping timeout
+ warning_ping_timeout: float = _health_defaults.warning_ping_timeout # Warning/Reconnecting 状态下的宽松超时
+ ping_timeout_http: float = _health_defaults.ping_timeout_http # HTTP 传输默认 ping 超时
+ ping_timeout_sse: float = _health_defaults.ping_timeout_sse # SSE 传输默认 ping 超时
+ ping_timeout_stdio: float = _health_defaults.ping_timeout_stdio # STDIO/Studio 传输默认 ping 超时
+ disconnection_timeout: float = _health_defaults.disconnection_timeout # Disconnection detection timeout
+
+ # Lifecycle timeouts
+ initialization_timeout: float = _service_defaults.initialization_timeout # Service initialization timeout
+ termination_timeout: float = _service_defaults.termination_timeout # Service termination timeout
+ shutdown_timeout: float = _service_defaults.shutdown_timeout # Graceful shutdown timeout
+
+ # Retry and restart behavior
+ restart_delay_seconds: float = 5.0 # Delay before restart attempt
+ max_restart_attempts: int = 3 # Maximum restart attempts
+
+ # Logging and monitoring
+ enable_detailed_logging: bool = True # Enable detailed lifecycle logging
+ collect_startup_metrics: bool = True # Collect startup performance metrics
+ collect_runtime_metrics: bool = True # Collect runtime performance metrics
+ collect_shutdown_metrics: bool = True # Collect shutdown performance metrics
diff --git a/src/mcpstore/config/config_defaults.py b/src/mcpstore/config/config_defaults.py
new file mode 100644
index 00000000..e0543255
--- /dev/null
+++ b/src/mcpstore/config/config_defaults.py
@@ -0,0 +1,277 @@
+"""
+Default configuration values for MCPStore.
+
+This module contains all the default configuration values that are used
+when TOML configuration is not provided or contains invalid values.
+"""
+
+from dataclasses import dataclass
+from typing import Dict, Any, Optional
+
+
+@dataclass
+class ServerConfigDefaults:
+ """Default server configuration."""
+ host: str = "0.0.0.0"
+ port: int = 18200
+ reload: bool = False
+ auto_open_browser: bool = False
+ show_startup_info: bool = True
+
+
+@dataclass
+class HealthCheckConfigDefaults:
+ """Default health check configuration."""
+ enabled: bool = True
+ warning_failure_threshold: int = 1
+ reconnecting_failure_threshold: int = 2
+ max_reconnect_attempts: int = 10
+ base_reconnect_delay: float = 1.0
+ max_reconnect_delay: float = 60.0
+ long_retry_interval: float = 300.0
+ # 健康状态的轻量心跳(默认 10s)
+ normal_heartbeat_interval: float = 10.0
+ warning_heartbeat_interval: float = 10.0
+ health_check_ping_timeout: float = 10.0
+ # 在 WARNING/RECONNECTING 状态下使用更宽松的 ping 超时,避免短暂抖动触发误判
+ warning_ping_timeout: float = 30.0
+ # 按传输类型的默认 ping 超时
+ ping_timeout_http: float = 20.0
+ ping_timeout_sse: float = 20.0
+ ping_timeout_stdio: float = 40.0
+ initialization_timeout: float = 300.0
+ disconnection_timeout: float = 10.0
+
+
+@dataclass
+class ServiceLifecycleConfigDefaults:
+ """Default service lifecycle timeouts and lifecycle-related settings.
+
+ These values complement HealthCheckConfigDefaults by providing higher-level
+ lifecycle timeouts and behavior controls (initialization/termination/shutdown
+ and restart behavior). They are used by ServiceLifecycleConfig in both
+ config_dataclasses.py and core.lifecycle.config.
+ """
+ # Lifecycle timeouts (seconds)
+ initialization_timeout: float = 300.0
+ termination_timeout: float = 60.0
+ shutdown_timeout: float = 30.0
+
+ # Retry and restart behavior
+ restart_delay_seconds: float = 5.0
+ max_restart_attempts: int = 3
+
+ # Logging and monitoring toggles
+ enable_detailed_logging: bool = True
+ collect_startup_metrics: bool = True
+ collect_runtime_metrics: bool = True
+ collect_shutdown_metrics: bool = True
+
+
+@dataclass
+class ContentUpdateConfigDefaults:
+ """Default content update configuration."""
+ tools_update_interval: float = 300.0 # 5 minutes
+ resources_update_interval: float = 600.0 # 10 minutes
+ prompts_update_interval: float = 600.0 # 10 minutes
+ max_concurrent_updates: int = 3
+ update_timeout: float = 30.0 # 30 seconds
+ max_consecutive_failures: int = 3
+ failure_backoff_multiplier: float = 2.0
+
+
+@dataclass
+class MonitoringConfigDefaults:
+ """Default monitoring configuration."""
+ health_check_seconds: int = 30
+ tools_update_hours: float = 2.0
+ reconnection_seconds: int = 60
+ cleanup_hours: float = 24.0
+ enable_tools_update: bool = True
+ enable_reconnection: bool = True
+ update_tools_on_reconnection: bool = True
+ detect_tools_changes: bool = False
+ local_service_ping_timeout: int = 3
+ remote_service_ping_timeout: int = 5
+ startup_wait_time: int = 2
+ healthy_response_threshold: float = 1.0
+ warning_response_threshold: float = 3.0
+ slow_response_threshold: float = 10.0
+ enable_adaptive_timeout: bool = True
+ adaptive_timeout_multiplier: float = 2.0
+ response_time_history_size: int = 10
+
+
+@dataclass
+class CacheMemoryConfigDefaults:
+ """Default memory cache configuration."""
+ timeout: float = 2.0
+ retry_attempts: int = 3
+ health_check: bool = True
+ max_size: Optional[int] = None
+ cleanup_interval: int = 300
+
+
+@dataclass
+class CacheRedisConfigDefaults:
+ """Default Redis cache configuration (excluding sensitive info)."""
+ timeout: float = 2.0
+ retry_attempts: int = 3
+ health_check: bool = True
+ max_connections: int = 50
+ retry_on_timeout: bool = True
+ socket_keepalive: bool = True
+ socket_connect_timeout: float = 5.0
+ socket_timeout: float = 5.0
+ health_check_interval: int = 30
+
+
+@dataclass
+class StandaloneConfigDefaults:
+ """Default standalone configuration."""
+ heartbeat_interval_seconds: float = 30.0
+ http_timeout_seconds: float = 10.0
+ reconnection_interval_seconds: float = 60.0
+ cleanup_interval_seconds: float = 300.0
+ default_transport: str = "stdio"
+ log_level: str = "INFO"
+ log_format: str = "json"
+ enable_debug: bool = False
+
+
+@dataclass
+class LoggingConfigDefaults:
+ """Default logging configuration."""
+ level: str = "INFO"
+ enable_debug: bool = False
+ format: str = "json"
+
+
+@dataclass
+class WrapperConfigDefaults:
+ """Default wrapper configuration."""
+ DEFAULT_MAX_ITEM_SIZE: int = 1048576 # 1MB
+ DEFAULT_COMPRESSION_THRESHOLD: int = 1024 # 1KB
+
+
+@dataclass
+class SyncConfigDefaults:
+ """Default sync configuration."""
+ debounce_delay: float = 1.0
+ min_sync_interval: float = 5.0
+
+
+@dataclass
+class TransactionConfigDefaults:
+ """Default transaction configuration."""
+ timeout: float = 30.0
+
+
+@dataclass
+class APIConfigDefaults:
+ """Default API configuration."""
+ enable_cors: bool = True
+ cors_origins: list = None
+ rate_limit_enabled: bool = False
+ rate_limit_requests: int = 100
+ rate_limit_window: int = 60
+
+ def __post_init__(self):
+ if self.cors_origins is None:
+ self.cors_origins = ["*"]
+
+
+@dataclass
+class ToolSetConfigDefaults:
+ """Default tool set configuration."""
+ enable_tool_set: bool = True
+ cache_ttl_seconds: int = 3600
+ max_tools_per_service: int = 1000
+
+
+def get_all_defaults() -> Dict[str, Dict[str, Any]]:
+ """
+ Get all default configuration values as a dictionary.
+
+ Note: Cache, wrapper, sync, transaction, api, tool_set, and logging configurations
+ are removed as they are not managed via TOML configuration files.
+
+ Returns:
+ Dictionary containing all default configurations grouped by section
+ """
+ server = ServerConfigDefaults()
+ health_check = HealthCheckConfigDefaults()
+ service_lifecycle = ServiceLifecycleConfigDefaults()
+ content_update = ContentUpdateConfigDefaults()
+ monitoring = MonitoringConfigDefaults()
+ standalone = StandaloneConfigDefaults()
+
+ return {
+ "server": {
+ "host": server.host,
+ "port": server.port,
+ "reload": server.reload,
+ "auto_open_browser": server.auto_open_browser,
+ "show_startup_info": server.show_startup_info,
+ },
+ "health_check": {
+ "enabled": health_check.enabled,
+ "warning_failure_threshold": health_check.warning_failure_threshold,
+ "reconnecting_failure_threshold": health_check.reconnecting_failure_threshold,
+ "max_reconnect_attempts": health_check.max_reconnect_attempts,
+ "base_reconnect_delay": health_check.base_reconnect_delay,
+ "max_reconnect_delay": health_check.max_reconnect_delay,
+ "long_retry_interval": health_check.long_retry_interval,
+ "normal_heartbeat_interval": health_check.normal_heartbeat_interval,
+ "warning_heartbeat_interval": health_check.warning_heartbeat_interval,
+ "health_check_ping_timeout": health_check.health_check_ping_timeout,
+ "initialization_timeout": health_check.initialization_timeout,
+ "disconnection_timeout": health_check.disconnection_timeout,
+ },
+ "content_update": {
+ "tools_update_interval": content_update.tools_update_interval,
+ "resources_update_interval": content_update.resources_update_interval,
+ "prompts_update_interval": content_update.prompts_update_interval,
+ "max_concurrent_updates": content_update.max_concurrent_updates,
+ "update_timeout": content_update.update_timeout,
+ "max_consecutive_failures": content_update.max_consecutive_failures,
+ "failure_backoff_multiplier": content_update.failure_backoff_multiplier,
+ },
+ "monitoring": {
+ "health_check_seconds": monitoring.health_check_seconds,
+ "tools_update_hours": monitoring.tools_update_hours,
+ "reconnection_seconds": monitoring.reconnection_seconds,
+ "cleanup_hours": monitoring.cleanup_hours,
+ "enable_tools_update": monitoring.enable_tools_update,
+ "enable_reconnection": monitoring.enable_reconnection,
+ "update_tools_on_reconnection": monitoring.update_tools_on_reconnection,
+ "detect_tools_changes": monitoring.detect_tools_changes,
+ "local_service_ping_timeout": monitoring.local_service_ping_timeout,
+ "remote_service_ping_timeout": monitoring.remote_service_ping_timeout,
+ "startup_wait_time": monitoring.startup_wait_time,
+ "healthy_response_threshold": monitoring.healthy_response_threshold,
+ "warning_response_threshold": monitoring.warning_response_threshold,
+ "slow_response_threshold": monitoring.slow_response_threshold,
+ "enable_adaptive_timeout": monitoring.enable_adaptive_timeout,
+ "adaptive_timeout_multiplier": monitoring.adaptive_timeout_multiplier,
+ "response_time_history_size": monitoring.response_time_history_size,
+ },
+ "standalone": {
+ "heartbeat_interval_seconds": standalone.heartbeat_interval_seconds,
+ "http_timeout_seconds": standalone.http_timeout_seconds,
+ "reconnection_interval_seconds": standalone.reconnection_interval_seconds,
+ "cleanup_interval_seconds": standalone.cleanup_interval_seconds,
+ "default_transport": standalone.default_transport,
+ "log_level": standalone.log_level,
+ "log_format": standalone.log_format,
+ "enable_debug": standalone.enable_debug,
+ },
+ # Note: Removed configurations not managed via TOML:
+ # - logging: Controlled by setup_store(debug=...) parameter
+ # - cache: Controlled by setup_store(cache=...) parameter
+ # - wrapper: Uses WrapperConfigDefaults in code
+ # - sync: Hardcoded in unified_sync_manager.py
+ # - transaction: Hardcoded in cache_manager.py
+ # - api: Not actually used
+ # - tool_set: Not actually used
+ }
diff --git a/src/mcpstore/config/health_check.py b/src/mcpstore/config/health_check.py
new file mode 100644
index 00000000..190b5e56
--- /dev/null
+++ b/src/mcpstore/config/health_check.py
@@ -0,0 +1,245 @@
+"""
+Health check functionality for Redis connections.
+
+This module provides background health check tasks for monitoring Redis
+connection health without blocking main operations.
+"""
+
+import asyncio
+import logging
+from typing import Optional, Any
+
+from redis.asyncio import Redis
+
+from mcpstore.core.bridge import get_async_bridge
+from .cache_config import RedisConfig
+
+logger = logging.getLogger(__name__)
+
+
+class RedisHealthCheck:
+ """
+ Background health check task for Redis connections.
+
+ This class manages a background task that periodically pings Redis
+ to verify connection health. It logs warnings on failure but does
+ not block main operations.
+
+ Attributes:
+ config: Redis configuration object
+ client: Redis client instance
+ task: Background asyncio task (if running)
+ _stop_event: Event to signal task shutdown
+ """
+
+ def __init__(self, config: RedisConfig, client: Redis):
+ """
+ Initialize health check.
+
+ Args:
+ config: Redis configuration with health_check_interval
+ client: Redis client to monitor
+ """
+ self.config = config
+ self.client = client
+ self.task: Optional[Any] = None
+ self._stop_event = asyncio.Event()
+ self._bridge_handle = None
+ self._bridge = get_async_bridge()
+
+ async def _health_check_loop(self):
+ """
+ Background loop that periodically pings Redis.
+
+ This method runs in a background task and executes PING commands
+ at the configured interval. Failures are logged as warnings but
+ do not raise exceptions or block operations.
+ """
+ interval = self.config.health_check_interval
+
+ logger.debug(
+ f"Starting Redis health check with interval: {interval}s"
+ )
+
+ while not self._stop_event.is_set():
+ try:
+ # Wait for the interval or until stop is signaled
+ await asyncio.wait_for(
+ self._stop_event.wait(),
+ timeout=interval
+ )
+ # If we get here, stop was signaled
+ break
+ except asyncio.TimeoutError:
+ # Timeout is expected - time to do health check
+ pass
+
+ # Perform health check
+ try:
+ await self.client.ping()
+ logger.debug("Redis health check: OK")
+ except Exception as e:
+ # Log health check failure with context
+ logger.warning(
+ f"Redis health check failed: {type(e).__name__}: {e}. "
+ f"Connection may be unstable. "
+ f"Namespace: {self.config.namespace or 'default'}, "
+ f"Interval: {self.config.health_check_interval}s"
+ )
+
+ def start(self):
+ """
+ Start the health check background task.
+
+ This method starts the background task if:
+ - health_check_interval > 0
+ - Task is not already running
+
+ The task runs independently and does not block the caller.
+
+ Note: This method can be called from both sync and async contexts.
+ It will automatically detect the context and use the appropriate
+ event loop.
+ """
+ # Only start if interval is positive
+ if self.config.health_check_interval <= 0:
+ logger.debug("Health check disabled (interval <= 0)")
+ return
+
+ # Don't start if already running
+ if self.task is not None and not self.task.done():
+ logger.debug("Health check already running")
+ return
+
+ # Try to get the running event loop
+ try:
+ asyncio.get_running_loop()
+ self.task = asyncio.create_task(self._health_check_loop())
+ logger.info(
+ f"Started Redis health check (interval: {self.config.health_check_interval}s)"
+ )
+ except RuntimeError:
+ try:
+ self._bridge_handle = self._bridge.create_background_task(
+ self._health_check_loop(),
+ op_name="redis.health_check"
+ )
+ self.task = self._bridge_handle
+ logger.info(
+ f"Started Redis health check in background bridge "
+ f"(interval: {self.config.health_check_interval}s)"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to start health check: {e}. "
+ f"Health monitoring will be disabled."
+ )
+
+ async def stop(self):
+ """
+ Stop the health check background task.
+
+ This method signals the background task to stop and waits for it
+ to complete gracefully.
+
+ Note: This is an async method and should be called with await.
+ For sync contexts, use stop_sync() instead.
+ """
+ if self.task is None:
+ return
+
+ # bridge handle case
+ if self._bridge_handle is not None:
+ self._stop_event.set()
+ try:
+ self._bridge_handle.cancel()
+ finally:
+ self._bridge_handle = None
+ self.task = None
+ self._stop_event = asyncio.Event()
+ return
+
+ # Check if task is done (works for both Task and Future)
+ try:
+ if self.task.done():
+ return
+ except AttributeError:
+ # If done() doesn't exist, assume it's not done
+ pass
+
+ # Signal the task to stop
+ self._stop_event.set()
+
+ # Wait for the task to complete
+ try:
+ # Handle both asyncio.Task and concurrent.futures.Future
+ if hasattr(self.task, '__await__'):
+ # It's an asyncio Task
+ await asyncio.wait_for(self.task, timeout=5.0)
+ else:
+ # It's a concurrent.futures.Future from run_coroutine_threadsafe
+ # We can't await it directly, just wait for completion
+ import concurrent.futures
+ try:
+ self.task.result(timeout=5.0)
+ except concurrent.futures.TimeoutError:
+ logger.warning("Health check task did not stop gracefully")
+ self.task.cancel()
+ except Exception as e:
+ # Task may have raised an exception, that's ok during shutdown
+ logger.debug(f"Health check task ended with: {e}")
+
+ logger.debug("Health check stopped")
+ except asyncio.TimeoutError:
+ logger.warning("Health check task did not stop gracefully")
+ if hasattr(self.task, 'cancel'):
+ self.task.cancel()
+ try:
+ await self.task
+ except asyncio.CancelledError:
+ pass
+ finally:
+ self.task = None
+ self._bridge_handle = None
+ self._stop_event = asyncio.Event()
+
+
+def start_health_check(
+ config: RedisConfig,
+ client: Redis
+) -> Optional[RedisHealthCheck]:
+ """
+ Start a health check background task for Redis connection.
+
+ This is a convenience function that creates and starts a health check
+ task based on the configuration. If health_check_interval is 0 or
+ negative, no task is created.
+
+ Args:
+ config: Redis configuration object
+ client: Redis client to monitor
+
+ Returns:
+ RedisHealthCheck instance if started, None if disabled
+
+ Examples:
+ >>> config = RedisConfig(
+ ... url="redis://localhost:6379/0",
+ ... health_check_interval=30
+ ... )
+ >>> client = Redis.from_url(config.url)
+ >>> health_check = start_health_check(config, client)
+ >>> # Later, when shutting down:
+ >>> if health_check:
+ ... await health_check.stop()
+ """
+ # Check if health check is enabled
+ if config.health_check_interval <= 0:
+ logger.debug("Health check disabled in configuration")
+ return None
+
+ # Create and start health check
+ health_check = RedisHealthCheck(config, client)
+ health_check.start()
+
+ return health_check
diff --git a/src/mcpstore/config/json_config.py b/src/mcpstore/config/json_config.py
new file mode 100644
index 00000000..0a0d3272
--- /dev/null
+++ b/src/mcpstore/config/json_config.py
@@ -0,0 +1,311 @@
+import json
+import logging
+import os
+from typing import Dict, Any, Optional, List
+
+from pydantic import BaseModel, model_validator, ConfigDict
+
+from .path_utils import get_user_default_mcp_path
+
+logger = logging.getLogger(__name__)
+
+# Backup strategy: Keep at most 1 backup per file, using .bak suffix
+
+class MCPServerModel(BaseModel):
+ """
+ Tolerant MCP service configuration model, supports all configuration formats of FastMCP Client
+ Reference: https://docs.fastmcp.com/clients/transports
+ """
+ # Remote service configuration
+ url: Optional[str] = None
+ transport: Optional[str] = None # Optional, Client will auto-infer
+ headers: Optional[Dict[str, str]] = None
+
+ # Local service configuration
+ command: Optional[str] = None
+ args: Optional[List[str]] = None
+ env: Optional[Dict[str, str]] = None
+
+ # General configuration
+ name: Optional[str] = None
+ description: Optional[str] = None
+ keep_alive: Optional[bool] = None
+ timeout: Optional[int] = None
+
+ # Allow extra fields, maintain maximum compatibility
+ model_config = ConfigDict(extra="allow")
+
+ @model_validator(mode='before')
+ @classmethod
+ def validate_basic_config(cls, values):
+ """Basic configuration validation: must have at least url or command"""
+ if not (values.get("url") or values.get("command")):
+ raise ValueError("MCP server must have either 'url' or 'command' field")
+
+ # 规范化 transport 字段:兼容常见非标准写法(http/sse)
+ transport = values.get("transport")
+ if isinstance(transport, str):
+ raw = transport.strip().lower()
+ mapping = {
+ "http": "http-first",
+ "sse": "sse-first",
+ "http_only": "http-only",
+ "sse_only": "sse-only",
+ }
+ normalized = mapping.get(raw, raw)
+ allowed = {"sse-only", "http-only", "sse-first", "http-first"}
+ if normalized not in allowed:
+ # 无效值:静默移除,让下游按默认逻辑处理(不额外提示用户)
+ values.pop("transport", None)
+ else:
+ # 对常见非标准写法做静默规范化,避免打扰用户
+ values["transport"] = normalized
+ return values
+
+class MCPConfigModel(BaseModel):
+ """
+ Tolerant MCP configuration model, supports FastMCP's configuration format
+ """
+ mcpServers: Dict[str, Dict[str, Any]] # Use Dict instead of strict MCPServerModel
+
+ # Allow extra fields
+ model_config = ConfigDict(extra="allow")
+
+ @model_validator(mode='before')
+ @classmethod
+ def ensure_mcpServers(cls, values):
+ if "mcpServers" not in values:
+ values["mcpServers"] = {}
+ return values
+
+class ConfigError(Exception):
+ """Base class for configuration errors"""
+ pass
+
+class ConfigValidationError(ConfigError):
+ """Raised when configuration validation fails"""
+ pass
+
+class ConfigIOError(ConfigError):
+ """Raised when configuration file operations fail"""
+ pass
+
+class MCPConfig:
+ """Handle loading, parsing and saving of mcp.json file"""
+
+ def __init__(self, json_path: str = None, client_id: str = "main"):
+ """Initialize configuration manager
+
+ Args:
+ json_path: Path to the configuration file
+ client_id: Client identifier for multi-client support
+ """
+ self._json_path = json_path or str(get_user_default_mcp_path())
+ self.client_id = client_id
+ logger.info(f"[CONFIG] MCP configuration initialized for client {client_id}, using file path: {self._json_path}")
+
+ @property
+ def json_path(self) -> str:
+ """Configuration file path (read-only)"""
+ return self._json_path
+
+ def _backup(self) -> None:
+ """Create a backup of the current configuration file"""
+ if not os.path.exists(self._json_path):
+ return
+
+ # Uniformly use .bak suffix, keep at most 1 backup per file
+ backup_path = f"{self._json_path}.bak"
+ try:
+ with open(self._json_path, 'rb') as src, open(backup_path, 'wb') as dst:
+ dst.write(src.read())
+ logger.info(f"[BACKUP] Backup created: {backup_path}")
+ except Exception as e:
+ logger.error(f"[BACKUP] Backup failed: {e}")
+ raise ConfigIOError(f"Failed to create backup: {e}")
+
+ def load_config(self) -> Dict[str, Any]:
+ """Load and validate configuration from file
+
+ Returns:
+ Dict containing the configuration
+
+ Raises:
+ ConfigIOError: If file operations fail
+ ConfigValidationError: If configuration is invalid
+ """
+ if not os.path.exists(self._json_path):
+ logger.warning(f"[CONFIG] Configuration file does not exist: {self._json_path}, creating empty file")
+ self.save_config({"mcpServers": {}})
+ return {"mcpServers": {}}
+
+ try:
+ with open(self._json_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ # Basic format check, but no strict validation
+ if not isinstance(data, dict):
+ raise ConfigValidationError("Configuration must be a dictionary")
+
+ if "mcpServers" in data and not isinstance(data["mcpServers"], dict):
+ raise ConfigValidationError("mcpServers must be a dictionary")
+
+ # No longer perform strict Pydantic validation, let FastMCP Client handle it
+ return data
+
+ except json.JSONDecodeError as e:
+ raise ConfigIOError(f"Failed to parse configuration file: {e}")
+ except Exception as e:
+ raise ConfigIOError(f"Error reading configuration file: {e}")
+
+ def save_config(self, config: Dict[str, Any]) -> bool:
+ """Save configuration to file with validation
+
+ Args:
+ config: Configuration dictionary to save
+
+ Returns:
+ bool: True if save was successful
+
+ Raises:
+ ConfigValidationError: If configuration is invalid
+ ConfigIOError: If file operations fail
+ """
+ # Basic format check, but no strict validation
+ if not isinstance(config, dict):
+ raise ConfigValidationError("Configuration must be a dictionary")
+
+ if "mcpServers" in config and not isinstance(config["mcpServers"], dict):
+ raise ConfigValidationError("mcpServers must be a dictionary")
+
+ # No longer perform strict Pydantic validation, let FastMCP Client handle it
+
+ self._backup()
+ tmp_path = f"{self._json_path}.tmp"
+
+ try:
+ with open(tmp_path, 'w', encoding='utf-8') as f:
+ json.dump(config, f, ensure_ascii=False, indent=2)
+ os.replace(tmp_path, self._json_path)
+ logger.info(f"Configuration saved successfully to {self._json_path}")
+ return True
+ except Exception as e:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ raise ConfigIOError(f"Failed to save configuration: {e}")
+
+ def get_service_config(self, name: str) -> Optional[Dict[str, Any]]:
+ """Get configuration for a specific service
+
+ Args:
+ name: Service name
+
+ Returns:
+ Optional[Dict]: Service configuration if found, None otherwise
+ """
+ config = self.load_config()
+ servers = config.get("mcpServers", {})
+ if name in servers:
+ result = dict(servers[name])
+ return result
+ return None
+
+ def get_all_services(self) -> List[Dict[str, Any]]:
+ """Get configuration for all services
+
+ Returns:
+ List[Dict]: List of service configurations
+ """
+ config = self.load_config()
+ servers = config.get("mcpServers", {})
+ return [{"name": name, **server_config} for name, server_config in servers.items()]
+
+ def update_service(self, name: str, config: Dict[str, Any]) -> bool:
+ """Update or add a service configuration
+
+ Args:
+ name: Service name
+ config: Service configuration
+
+ Returns:
+ bool: True if update was successful
+
+ Raises:
+ ConfigValidationError: If service configuration is invalid
+ """
+ # Basic format check, but no strict validation
+ if not isinstance(config, dict):
+ raise ConfigValidationError("Service configuration must be a dictionary")
+
+ # Check basic requirements: must have at least url or command
+ if not (config.get("url") or config.get("command")):
+ available_fields = list(config.keys())
+ raise ConfigValidationError(
+ f"Service must have either 'url' or 'command' field. "
+ f"Current config has: {available_fields}. "
+ f"Tip: For incremental updates, use patch_service() instead of update_service()."
+ )
+
+ # No longer perform strict Pydantic validation, let FastMCP Client handle it
+
+ current_config = self.load_config()
+ current_config["mcpServers"][name] = config
+ return self.save_config(current_config)
+
+ def update_service_config(self, name: str, config: Dict[str, Any]) -> bool:
+ """Update service configuration (alias for update_service)
+
+ Args:
+ name: Service name
+ config: Service configuration
+
+ Returns:
+ bool: True if update was successful
+ """
+ return self.update_service(name, config)
+
+ def remove_service(self, name: str) -> bool:
+ """Remove a service configuration
+
+ Args:
+ name: Service name
+
+ Returns:
+ bool: True if removal was successful
+ """
+ config = self.load_config()
+ servers = config.get("mcpServers", {})
+ if name in servers:
+ del servers[name]
+ config["mcpServers"] = servers
+ return self.save_config(config)
+ return False
+
+ def reset_mcp_json_file(self) -> bool:
+ """
+ Directly reset MCP JSON configuration file
+ 1. Backup current configuration file
+ 2. Reset configuration to empty dictionary {"mcpServers": {}}
+
+ Returns:
+ Whether reset was successful
+ """
+ try:
+ import shutil
+ from datetime import datetime
+
+ # Create backup
+ backup_path = f"{self._json_path}.bak"
+ shutil.copy2(self._json_path, backup_path)
+ logger.info(f"Created backup at {backup_path}")
+
+ # Reset to empty configuration
+ empty_config = {"mcpServers": {}}
+ self.save_config(empty_config)
+
+ logger.info(f"Successfully reset MCP JSON configuration file: {self._json_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to reset MCP JSON configuration file: {e}")
+ return False
diff --git a/src/mcpstore/config/path_utils.py b/src/mcpstore/config/path_utils.py
new file mode 100644
index 00000000..4b65a575
--- /dev/null
+++ b/src/mcpstore/config/path_utils.py
@@ -0,0 +1,35 @@
+"""
+Path utilities for MCPStore configuration
+"""
+
+from pathlib import Path
+
+
+def get_user_default_mcp_path() -> Path:
+ """
+ Get the default user-level mcp.json path
+
+ Returns:
+ Path: User-level default path (~/.mcpstore/mcp.json)
+ """
+ return Path.home() / ".mcpstore" / "mcp.json"
+
+
+def get_user_data_dir() -> Path:
+ """
+ Get the user-level data directory
+
+ Returns:
+ Path: User-level data directory (~/.mcpstore)
+ """
+ return Path.home() / ".mcpstore"
+
+
+def get_user_config_path() -> Path:
+ """
+ Get the user-level config.toml path
+
+ Returns:
+ Path: User-level config path (~/.mcpstore/config.toml)
+ """
+ return Path.home() / ".mcpstore" / "config.toml"
diff --git a/src/mcpstore/config/redis_errors.py b/src/mcpstore/config/redis_errors.py
new file mode 100644
index 00000000..bd42a11c
--- /dev/null
+++ b/src/mcpstore/config/redis_errors.py
@@ -0,0 +1,370 @@
+"""
+Redis connection error handling module.
+
+This module provides comprehensive error handling for Redis connection failures,
+including detailed error messages, password masking, and troubleshooting guidance.
+"""
+
+import logging
+from typing import Dict, Any
+
+from redis.exceptions import (
+ ConnectionError as RedisConnectionError,
+ AuthenticationError,
+ TimeoutError as RedisTimeoutError,
+ ResponseError,
+ RedisError
+)
+
+logger = logging.getLogger(__name__)
+
+
+class RedisConnectionFailure(Exception):
+ """
+ Custom exception for Redis connection failures with detailed context.
+
+ This exception provides:
+ - Masked connection details (passwords hidden)
+ - Specific error categorization
+ - Troubleshooting steps
+ - Original exception for debugging
+ """
+
+ def __init__(
+ self,
+ message: str,
+ connection_info: Dict[str, Any],
+ original_error: Exception,
+ troubleshooting_steps: list[str]
+ ):
+ self.message = message
+ self.connection_info = connection_info
+ self.original_error = original_error
+ self.troubleshooting_steps = troubleshooting_steps
+
+ # Build comprehensive error message
+ full_message = self._build_error_message()
+ super().__init__(full_message)
+
+ def _build_error_message(self) -> str:
+ """Build a comprehensive error message with all context."""
+ lines = [
+ "",
+ "=" * 80,
+ "Redis Connection Failure",
+ "=" * 80,
+ "",
+ f"Error: {self.message}",
+ "",
+ "Connection Details:",
+ ]
+
+ # Add connection info (passwords already masked)
+ for key, value in self.connection_info.items():
+ lines.append(f" {key}: {value}")
+
+ lines.extend([
+ "",
+ "Original Error:",
+ f" {type(self.original_error).__name__}: {str(self.original_error)}",
+ "",
+ "Troubleshooting Steps:",
+ ])
+
+ # Add numbered troubleshooting steps
+ for i, step in enumerate(self.troubleshooting_steps, 1):
+ lines.append(f" {i}. {step}")
+
+ lines.extend([
+ "",
+ "=" * 80,
+ ""
+ ])
+
+ return "\n".join(lines)
+
+
+def mask_password_in_url(url: str) -> str:
+ """
+ Mask password in Redis URL for safe logging.
+
+ Args:
+ url: Redis URL (e.g., redis://user:password@host:port/db)
+
+ Returns:
+ URL with password masked (e.g., redis://user:***@host:port/db)
+
+ Examples:
+ >>> mask_password_in_url("redis://localhost:6379/0")
+ 'redis://localhost:6379/0'
+
+ >>> mask_password_in_url("redis://:mypass@localhost:6379/0")
+ 'redis://:***@localhost:6379/0'
+
+ >>> mask_password_in_url("redis://user:secret@localhost:6379/0")
+ 'redis://user:***@localhost:6379/0'
+ """
+ if not url:
+ return url
+
+ # Check if URL contains authentication
+ if '@' not in url or '://' not in url:
+ return url
+
+ try:
+ # Split by protocol
+ parts = url.split('://', 1)
+ if len(parts) != 2:
+ return url
+
+ protocol = parts[0]
+ rest = parts[1]
+
+ # Check if there's authentication
+ if '@' not in rest:
+ return url
+
+ # Split authentication and host parts
+ auth_and_host = rest.split('@', 1)
+ if len(auth_and_host) != 2:
+ return url
+
+ auth_part = auth_and_host[0]
+ host_part = auth_and_host[1]
+
+ # Mask password in auth part
+ if ':' in auth_part:
+ # Format: user:password or :password
+ auth_components = auth_part.split(':', 1)
+ masked_auth = f"{auth_components[0]}:***"
+ else:
+ # No password, just username
+ masked_auth = auth_part
+
+ # Reconstruct URL
+ return f"{protocol}://{masked_auth}@{host_part}"
+
+ except Exception:
+ # If anything goes wrong, return original URL
+ # (better to show password than crash)
+ return url
+
+
+def get_connection_info(config: "RedisConfig") -> Dict[str, Any]:
+ """
+ Extract connection information from RedisConfig with password masking.
+
+ Args:
+ config: RedisConfig instance
+
+ Returns:
+ Dictionary with masked connection details
+ """
+ info = {}
+
+ if config.url:
+ info["url"] = mask_password_in_url(config.url)
+ else:
+ info["host"] = config.host or "localhost"
+ info["port"] = config.port or 6379
+ info["db"] = config.db or 0
+ if config.password:
+ info["password"] = "***"
+
+ info["namespace"] = config.namespace or "mcpstore"
+ info["max_connections"] = config.max_connections
+ info["socket_timeout"] = f"{config.socket_timeout}s"
+ info["socket_connect_timeout"] = f"{config.socket_connect_timeout}s"
+
+ return info
+
+
+def handle_redis_connection_error(
+ error: Exception,
+ config: "RedisConfig"
+) -> RedisConnectionFailure:
+ """
+ Handle Redis connection errors and provide detailed troubleshooting.
+
+ This function categorizes Redis errors and provides specific guidance:
+ - Authentication errors: Check password and Redis AUTH configuration
+ - Network errors: Check Redis server status and network connectivity
+ - Timeout errors: Check network latency and timeout settings
+ - Other errors: General troubleshooting steps
+
+ Args:
+ error: The original exception raised during connection
+ config: RedisConfig instance with connection details
+
+ Returns:
+ RedisConnectionFailure with detailed context and troubleshooting
+
+ Examples:
+ >>> try:
+ ... # Connection attempt
+ ... pass
+ ... except Exception as e:
+ ... raise handle_redis_connection_error(e, redis_config)
+ """
+ conn_info = get_connection_info(config)
+
+ # Categorize error and provide specific guidance
+ if isinstance(error, AuthenticationError):
+ message = "Redis authentication failed"
+ troubleshooting = [
+ "Verify the Redis password is correct",
+ "Check if Redis server requires authentication (requirepass in redis.conf)",
+ "Ensure the password matches the Redis server configuration",
+ "Try connecting with redis-cli to verify credentials: redis-cli -h -p -a ",
+ "Check Redis ACL rules if using Redis 6+ ACL system"
+ ]
+
+ elif isinstance(error, (RedisConnectionError, OSError)):
+ # Network connectivity issues
+ message = "Cannot connect to Redis server"
+ troubleshooting = [
+ "Verify Redis server is running: redis-cli ping",
+ "Check if Redis is listening on the specified host and port",
+ "Verify network connectivity: ping ",
+ "Check firewall rules allow connections to Redis port",
+ "Ensure Redis bind address allows connections from your IP",
+ "Check Redis logs for startup errors: tail -f /var/log/redis/redis-server.log"
+ ]
+
+ elif isinstance(error, RedisTimeoutError):
+ message = "Redis connection timeout"
+ troubleshooting = [
+ f"Increase socket_connect_timeout (current: {config.socket_connect_timeout}s)",
+ f"Increase socket_timeout (current: {config.socket_timeout}s)",
+ "Check network latency to Redis server",
+ "Verify Redis server is not overloaded: redis-cli --latency",
+ "Check if Redis is performing slow operations: redis-cli slowlog get"
+ ]
+
+ elif isinstance(error, ResponseError):
+ message = "Redis server returned an error response"
+ troubleshooting = [
+ "Check Redis server logs for detailed error information",
+ "Verify Redis server version compatibility",
+ "Check if Redis is in protected mode: CONFIG GET protected-mode",
+ "Ensure Redis commands are not disabled in configuration"
+ ]
+
+ elif isinstance(error, RedisError):
+ message = f"Redis error: {type(error).__name__}"
+ troubleshooting = [
+ "Check Redis server logs for detailed error information",
+ "Verify Redis server is healthy: redis-cli ping",
+ "Check Redis server memory usage: redis-cli info memory",
+ "Review Redis configuration for any restrictions"
+ ]
+
+ else:
+ # Generic error
+ message = f"Unexpected error connecting to Redis: {type(error).__name__}"
+ troubleshooting = [
+ "Check Redis server status and logs",
+ "Verify all connection parameters are correct",
+ "Try connecting with redis-cli to isolate the issue",
+ "Check system resources (memory, file descriptors)",
+ "Review application logs for additional context"
+ ]
+
+ # Add common troubleshooting steps
+ troubleshooting.extend([
+ "",
+ "Example working configuration:",
+ " RedisConfig(url='redis://localhost:6379/0')",
+ " RedisConfig(host='localhost', port=6379, db=0, password='your_password')"
+ ])
+
+ # Log the error with details
+ logger.error(
+ f"Redis connection failed: {message}",
+ extra={
+ "connection_info": conn_info,
+ "error_type": type(error).__name__,
+ "error_message": str(error)
+ },
+ exc_info=True
+ )
+
+ return RedisConnectionFailure(
+ message=message,
+ connection_info=conn_info,
+ original_error=error,
+ troubleshooting_steps=troubleshooting
+ )
+
+
+async def test_redis_connection(config: "RedisConfig") -> None:
+ """
+ Test Redis connection with fail-fast strategy.
+
+ This function attempts to connect to Redis and execute a PING command
+ to verify the connection is working. If the connection fails, it raises
+ a detailed RedisConnectionFailure exception.
+
+ Args:
+ config: RedisConfig instance to test
+
+ Raises:
+ RedisConnectionFailure: If connection fails with detailed troubleshooting
+
+ Examples:
+ >>> config = RedisConfig(url="redis://localhost:6379/0")
+ >>> await test_redis_connection(config) # Raises if connection fails
+ """
+ from redis.asyncio import Redis
+
+ client = None
+ try:
+ # Create a test client
+ if config.client:
+ # Use provided client
+ client = config.client
+ close_client = False
+ elif config.url:
+ # Create from URL
+ client = Redis.from_url(
+ config.url,
+ socket_connect_timeout=config.socket_connect_timeout,
+ socket_timeout=config.socket_timeout,
+ max_connections=config.max_connections,
+ socket_keepalive=config.socket_keepalive
+ )
+ close_client = True
+ else:
+ # Create from parameters
+ client = Redis(
+ host=config.host,
+ port=config.port or 6379,
+ db=config.db or 0,
+ password=config.password,
+ socket_connect_timeout=config.socket_connect_timeout,
+ socket_timeout=config.socket_timeout,
+ max_connections=config.max_connections,
+ socket_keepalive=config.socket_keepalive
+ )
+ close_client = True
+
+ # Test connection with PING
+ logger.debug("Testing Redis connection with PING command...")
+ response = await client.ping()
+
+ if not response:
+ raise RedisConnectionError("PING command failed")
+
+ logger.debug("Redis connection test successful")
+
+ except Exception as e:
+ # Handle and re-raise with detailed context
+ raise handle_redis_connection_error(e, config)
+
+ finally:
+ # Close test client if we created it
+ if client and close_client:
+ try:
+ await client.aclose()
+ except Exception:
+ pass
diff --git a/src/mcpstore/config/toml_config.py b/src/mcpstore/config/toml_config.py
new file mode 100644
index 00000000..b97d41a5
--- /dev/null
+++ b/src/mcpstore/config/toml_config.py
@@ -0,0 +1,1689 @@
+"""
+MCPStore TOML Configuration Management
+
+This module provides unified configuration management for MCPStore using TOML files.
+It handles initialization, loading, validation, and provides the MCPStoreConfig class.
+
+Task T1: Configuration directory and default TOML file initialization
+Task T2: TOML loading, default value merging, and validation pipeline
+"""
+
+import logging
+from pathlib import Path
+from typing import Optional, Dict, Any, Union, Tuple, List
+
+import toml
+
+from .config_defaults import (
+ get_all_defaults,
+ ServerConfigDefaults,
+ HealthCheckConfigDefaults,
+ ContentUpdateConfigDefaults,
+ MonitoringConfigDefaults,
+ CacheMemoryConfigDefaults,
+ CacheRedisConfigDefaults,
+ StandaloneConfigDefaults,
+ LoggingConfigDefaults,
+)
+from .path_utils import get_user_data_dir, get_user_config_path
+
+logger = logging.getLogger(__name__)
+
+_server_defaults = ServerConfigDefaults()
+_health_defaults = HealthCheckConfigDefaults()
+_content_defaults = ContentUpdateConfigDefaults()
+_monitoring_defaults = MonitoringConfigDefaults()
+_cache_memory_defaults = CacheMemoryConfigDefaults()
+_cache_redis_defaults = CacheRedisConfigDefaults()
+_standalone_defaults = StandaloneConfigDefaults()
+_logging_defaults = LoggingConfigDefaults()
+
+# 尝试导入其他配置类,处理可能的导入失败
+try:
+ from ..core.lifecycle.config import ServiceLifecycleConfig
+except ImportError as e:
+ print(f"Warning: ServiceLifecycleConfig not available: {e}")
+ ServiceLifecycleConfig = None
+
+try:
+ from ..core.configuration.standalone_config import StandaloneConfig
+except ImportError as e:
+ print(f"Warning: StandaloneConfig not available: {e}")
+ StandaloneConfig = None
+
+
+def ensure_config_directory() -> Path:
+ """
+ Ensure the configuration directory exists.
+
+ Returns:
+ Path: The configuration directory path
+ """
+ config_dir = get_user_data_dir()
+ config_dir.mkdir(parents=True, exist_ok=True)
+ return config_dir
+
+
+def get_default_config_template() -> str:
+ """
+ Get the default configuration template.
+
+ Returns:
+ str: Default TOML configuration template
+ """
+ defaults = get_all_defaults()
+
+ def _bool(value: bool) -> str:
+ return "true" if value else "false"
+
+ def _list(values: List[Any]) -> str:
+ parts = []
+ for v in values:
+ if isinstance(v, str):
+ parts.append(f'"{v}"')
+ else:
+ parts.append(str(v))
+ return "[" + ", ".join(parts) + "]"
+
+ server = defaults["server"]
+ health = defaults["health_check"]
+ content = defaults["content_update"]
+ monitoring = defaults["monitoring"]
+ standalone = defaults["standalone"]
+ # Note: Removed configurations that are not managed via TOML:
+ # cache, wrapper, sync, transaction, api, tool_set, logging
+
+ return f'''# =============================================================================
+# MCPStore 统一配置文件
+# 自动生成,用户可修改
+# 描述:统一管理所有非敏感配置项,包含健康检查、监控、日志等配置
+#
+# 注意:缓存配置由代码参数控制,不在此文件中配置
+# 使用示例:MCPStore.setup_store(cache=RedisConfig(url="redis://localhost:6379/0"))
+
+[server]
+# API服务器配置
+host = "{server["host"]}"
+port = {server["port"]}
+reload = {_bool(server["reload"])}
+auto_open_browser = {_bool(server["auto_open_browser"])}
+show_startup_info = {_bool(server["show_startup_info"])}
+
+[health_check]
+# 健康检查配置
+enabled = {_bool(health["enabled"])}
+warning_failure_threshold = {health["warning_failure_threshold"]}
+reconnecting_failure_threshold = {health["reconnecting_failure_threshold"]}
+max_reconnect_attempts = {health["max_reconnect_attempts"]}
+base_reconnect_delay = {health["base_reconnect_delay"]}
+max_reconnect_delay = {health["max_reconnect_delay"]}
+long_retry_interval = {health["long_retry_interval"]}
+normal_heartbeat_interval = {health["normal_heartbeat_interval"]}
+warning_heartbeat_interval = {health["warning_heartbeat_interval"]}
+health_check_ping_timeout = {health["health_check_ping_timeout"]}
+ping_timeout_http = {health["ping_timeout_http"]}
+ping_timeout_sse = {health["ping_timeout_sse"]}
+ping_timeout_stdio = {health["ping_timeout_stdio"]}
+initialization_timeout = {health["initialization_timeout"]}
+disconnection_timeout = {health["disconnection_timeout"]}
+
+[content_update]
+# 内容更新配置
+tools_update_interval = {content["tools_update_interval"]}
+resources_update_interval = {content["resources_update_interval"]}
+prompts_update_interval = {content["prompts_update_interval"]}
+max_concurrent_updates = {content["max_concurrent_updates"]}
+update_timeout = {content["update_timeout"]}
+max_consecutive_failures = {content["max_consecutive_failures"]}
+failure_backoff_multiplier = {content["failure_backoff_multiplier"]}
+
+[monitoring]
+# 监控系统配置
+health_check_seconds = {monitoring["health_check_seconds"]}
+tools_update_hours = {monitoring["tools_update_hours"]}
+reconnection_seconds = {monitoring["reconnection_seconds"]}
+cleanup_hours = {monitoring["cleanup_hours"]}
+enable_tools_update = {_bool(monitoring["enable_tools_update"])}
+enable_reconnection = {_bool(monitoring["enable_reconnection"])}
+update_tools_on_reconnection = {_bool(monitoring["update_tools_on_reconnection"])}
+detect_tools_changes = {_bool(monitoring["detect_tools_changes"])}
+local_service_ping_timeout = {monitoring["local_service_ping_timeout"]}
+remote_service_ping_timeout = {monitoring["remote_service_ping_timeout"]}
+startup_wait_time = {monitoring["startup_wait_time"]}
+healthy_response_threshold = {monitoring["healthy_response_threshold"]}
+warning_response_threshold = {monitoring["warning_response_threshold"]}
+slow_response_threshold = {monitoring["slow_response_threshold"]}
+enable_adaptive_timeout = {_bool(monitoring["enable_adaptive_timeout"])}
+adaptive_timeout_multiplier = {monitoring["adaptive_timeout_multiplier"]}
+response_time_history_size = {monitoring["response_time_history_size"]}
+
+[standalone]
+# 独立运行模式配置
+heartbeat_interval_seconds = {standalone["heartbeat_interval_seconds"]}
+http_timeout_seconds = {standalone["http_timeout_seconds"]}
+reconnection_interval_seconds = {standalone["reconnection_interval_seconds"]}
+cleanup_interval_seconds = {standalone["cleanup_interval_seconds"]}
+# streamable_http_endpoint = null # Not used in default config
+default_transport = "{standalone["default_transport"]}"
+log_level = "{standalone["log_level"]}"
+log_format = "{standalone["log_format"]}"
+enable_debug = {_bool(standalone["enable_debug"])}
+
+# =============================================================================
+# 以下配置项已移除(不通过TOML管理):
+# - [logging] : 由 setup_store(debug=...) 参数控制
+# - [cache] / [cache.memory] / [cache.redis] : 由 setup_store(cache=...) 参数控制
+# - [wrapper] : 使用代码中的 WrapperConfigDefaults
+# - [sync] : 硬编码在 unified_sync_manager.py
+# - [transaction] : 硬编码在 cache_manager.py
+# - [api] : 未实际使用
+# - [tool_set] : 未实际使用
+# =============================================================================
+'''
+
+
+def create_default_config_if_not_exists() -> bool:
+ """
+ Create default config.toml file if it doesn't exist.
+
+ Returns:
+ bool: True if file was created or already exists, False if there was an error
+ """
+ try:
+ # Ensure config directory exists
+ config_dir = ensure_config_directory()
+ config_path = get_user_config_path()
+
+ if config_path.exists():
+ logger.debug(f"Configuration file already exists: {config_path}")
+ return True
+
+ # Create default config file
+ logger.info(f"Creating default configuration file: {config_path}")
+
+ default_content = get_default_config_template()
+
+ with open(config_path, 'w', encoding='utf-8') as f:
+ f.write(default_content)
+
+ logger.info(f"Default configuration file created successfully: {config_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to create default configuration file: {e}")
+ return False
+
+
+def initialize_config_system() -> bool:
+ """
+ Initialize the configuration system by ensuring directories and files exist.
+
+ This function should be called early in the application startup process.
+
+ Returns:
+ bool: True if initialization succeeded, False otherwise
+ """
+ try:
+ logger.info("Initializing MCPStore configuration system...")
+
+ # Ensure configuration directory exists
+ config_dir = ensure_config_directory()
+ logger.debug(f"Configuration directory ensured: {config_dir}")
+
+ # Create default config.toml if it doesn't exist
+ config_created = create_default_config_if_not_exists()
+ if not config_created:
+ logger.warning("Failed to create default configuration file, continuing...")
+
+ logger.info("MCPStore configuration system initialized successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to initialize configuration system: {e}")
+ return False
+
+
+class ConfigValidator:
+ """Configuration validation and processing class for T2."""
+
+ # Validation rules for configuration values
+ # Note: Cache configuration removed - managed via setup_store(cache=...) parameter
+ VALIDATION_RULES = {
+ # Server configuration
+ "server.port": {"min": 1000, "max": 65535, "type": int},
+ "server.host": {"type": str, "allow_empty": False},
+ "server.reload": {"type": bool},
+ "server.auto_open_browser": {"type": bool},
+ "server.show_startup_info": {"type": bool},
+
+ # Health check configuration
+ "health_check.enabled": {"type": bool},
+ "health_check.warning_failure_threshold": {"min": 0, "max": 10, "type": int},
+ "health_check.reconnecting_failure_threshold": {"min": 0, "max": 10, "type": int},
+ "health_check.max_reconnect_attempts": {"min": 1, "max": 100, "type": int},
+ "health_check.base_reconnect_delay": {"min": 0.1, "max": 300.0, "type": float},
+ "health_check.max_reconnect_delay": {"min": 1.0, "max": 3600.0, "type": float},
+ "health_check.long_retry_interval": {"min": 10.0, "max": 7200.0, "type": float},
+ "health_check.normal_heartbeat_interval": {"min": 1.0, "max": 300.0, "type": float},
+ "health_check.warning_heartbeat_interval": {"min": 1.0, "max": 300.0, "type": float},
+ "health_check.health_check_ping_timeout": {"min": 0.1, "max": 300.0, "type": float},
+ "health_check.initialization_timeout": {"min": 1.0, "max": 1800.0, "type": float},
+ "health_check.disconnection_timeout": {"min": 0.1, "max": 300.0, "type": float},
+
+ # Content update configuration
+ "content_update.tools_update_interval": {"min": 10.0, "max": 86400.0, "type": float},
+ "content_update.resources_update_interval": {"min": 10.0, "max": 86400.0, "type": float},
+ "content_update.prompts_update_interval": {"min": 10.0, "max": 86400.0, "type": float},
+ "content_update.max_concurrent_updates": {"min": 1, "max": 20, "type": int},
+ "content_update.update_timeout": {"min": 5.0, "max": 600.0, "type": float},
+ "content_update.max_consecutive_failures": {"min": 1, "max": 10, "type": int},
+ "content_update.failure_backoff_multiplier": {"min": 1.0, "max": 10.0, "type": float},
+
+ # Monitoring configuration
+ "monitoring.enable_tools_update": {"type": bool},
+ "monitoring.enable_reconnection": {"type": bool},
+ "monitoring.update_tools_on_reconnection": {"type": bool},
+ "monitoring.detect_tools_changes": {"type": bool},
+ "monitoring.enable_adaptive_timeout": {"type": bool},
+ "monitoring.health_check_seconds": {"min": 5, "max": 600, "type": int},
+ "monitoring.tools_update_hours": {"min": 0.1, "max": 168, "type": float},
+ "monitoring.reconnection_seconds": {"min": 5, "max": 1800, "type": int},
+ "monitoring.cleanup_hours": {"min": 0.1, "max": 168, "type": float},
+ "monitoring.local_service_ping_timeout": {"min": 1, "max": 60, "type": int},
+ "monitoring.remote_service_ping_timeout": {"min": 1, "max": 120, "type": int},
+ "monitoring.startup_wait_time": {"min": 0, "max": 60, "type": int},
+ "monitoring.healthy_response_threshold": {"min": 0.1, "max": 10.0, "type": float},
+ "monitoring.warning_response_threshold": {"min": 0.5, "max": 30.0, "type": float},
+ "monitoring.slow_response_threshold": {"min": 1.0, "max": 120.0, "type": float},
+ "monitoring.adaptive_timeout_multiplier": {"min": 1.0, "max": 5.0, "type": float},
+ "monitoring.response_time_history_size": {"min": 5, "max": 100, "type": int},
+
+ # Standalone configuration
+ "standalone.heartbeat_interval_seconds": {"min": 1.0, "max": 300.0, "type": float},
+ "standalone.http_timeout_seconds": {"min": 1.0, "max": 300.0, "type": float},
+ "standalone.reconnection_interval_seconds": {"min": 1.0, "max": 1800.0, "type": float},
+ "standalone.cleanup_interval_seconds": {"min": 10.0, "max": 3600.0, "type": float},
+ "standalone.default_transport": {"type": str, "allowed_values": ["stdio", "sse", "websocket"]},
+ "standalone.enable_debug": {"type": bool},
+ "standalone.log_level": {"type": str, "allowed_values": ["DEBUG", "INFO", "WARNING", "ERROR"]},
+ "standalone.log_format": {"type": str, "allowed_values": ["json", "text"]},
+
+ # Note: Following configurations removed (not managed via TOML):
+ # - logging.* : Controlled by setup_store(debug=...) parameter
+ # - api.* : Not actually used
+ # - wrapper.* : Uses WrapperConfigDefaults in code
+ # - sync.* : Hardcoded in unified_sync_manager.py
+ # - transaction.* : Hardcoded in cache_manager.py
+ # - tool_set.* : Not actually used
+ }
+
+ @classmethod
+ def validate_config_key(cls, key: str, value: Any) -> Tuple[bool, Any, str]:
+ """
+ Validate a single configuration key-value pair.
+
+ Args:
+ key: Configuration key (e.g., "server.port")
+ value: Value to validate
+
+ Returns:
+ Tuple of (is_valid, normalized_value, error_message)
+ """
+ if key not in cls.VALIDATION_RULES:
+ # Unknown key, but allow it with a warning
+ return True, value, f"Unknown configuration key: {key}"
+
+ rules = cls.VALIDATION_RULES[key]
+
+ # Type validation
+ if "type" in rules:
+ expected_type = rules["type"]
+ try:
+ if expected_type == bool and isinstance(value, str):
+ # Allow string representation of boolean
+ normalized_value = value.lower() in ("true", "1", "yes", "on")
+ else:
+ normalized_value = expected_type(value)
+ except (ValueError, TypeError):
+ return False, value, f"Invalid type for {key}: expected {expected_type.__name__}, got {type(value).__name__}"
+ else:
+ normalized_value = value
+
+ # Range validation
+ if "min" in rules and normalized_value < rules["min"]:
+ return False, value, f"Value too small for {key}: {normalized_value} < {rules['min']}"
+
+ if "max" in rules and normalized_value > rules["max"]:
+ return False, value, f"Value too large for {key}: {normalized_value} > {rules['max']}"
+
+ # Allowed values validation
+ if "allowed_values" in rules and normalized_value not in rules["allowed_values"]:
+ return False, value, f"Invalid value for {key}: {normalized_value} not in {rules['allowed_values']}"
+
+ # Empty string validation
+ if not rules.get("allow_empty", True) and isinstance(normalized_value, str) and not normalized_value.strip():
+ return False, value, f"Empty value not allowed for {key}"
+
+ return True, normalized_value, ""
+
+ @classmethod
+ def validate_and_fix_config(cls, config: Dict[str, Any], defaults: Dict[str, Any]) -> Tuple[Dict[str, Any], int]:
+ """
+ Validate configuration and fix invalid values by using defaults.
+
+ Args:
+ config: User configuration loaded from TOML
+ defaults: Default configuration values
+
+ Returns:
+ Tuple of (validated_config, warning_count)
+ """
+ validated_config = {}
+ warning_count = 0
+
+ def process_section(section_config: Dict[str, Any], section_defaults: Dict[str, Any], section_prefix: str = ""):
+ nonlocal validated_config, warning_count
+
+ for key, default_value in section_defaults.items():
+ full_key = f"{section_prefix}.{key}" if section_prefix else key
+
+ if section_prefix:
+ # Handle nested sections
+ if "." in key:
+ # This shouldn't happen with our structure, but just in case
+ continue
+ else:
+ # Top-level section
+ if key in config and isinstance(config[key], dict):
+ # This is a section, process it separately
+ validated_config[key] = {}
+ process_section(config.get(key, {}), default_value, key)
+ continue
+
+ # Process individual key-value pairs
+ for key, user_value in section_config.items():
+ full_key = f"{section_prefix}.{key}" if section_prefix else key
+
+ if full_key in cls.VALIDATION_RULES:
+ is_valid, normalized_value, error_msg = cls.validate_config_key(full_key, user_value)
+ if is_valid:
+ # Use nested assignment logic
+ if section_prefix:
+ if section_prefix not in validated_config:
+ validated_config[section_prefix] = {}
+ validated_config[section_prefix][key] = normalized_value
+ else:
+ validated_config[key] = normalized_value
+ else:
+ # Use default value and log warning
+ default_value = defaults.get(section_prefix, {}).get(key, section_defaults.get(key))
+ if default_value is not None:
+ if section_prefix:
+ if section_prefix not in validated_config:
+ validated_config[section_prefix] = {}
+ validated_config[section_prefix][key] = default_value
+ else:
+ validated_config[key] = default_value
+
+ logger.warning(f"Configuration validation failed for {full_key}: {error_msg}. Using default value: {default_value}")
+ warning_count += 1
+ else:
+ # Unknown key, preserve as-is but log warning
+ if section_prefix:
+ if section_prefix not in validated_config:
+ validated_config[section_prefix] = {}
+ validated_config[section_prefix][key] = user_value
+ else:
+ validated_config[key] = user_value
+
+ logger.warning(f"Unknown configuration key: {full_key}")
+ warning_count += 1
+
+ # Process all sections
+ for section_name, section_config in config.items():
+ if isinstance(section_config, dict):
+ section_defaults = defaults.get(section_name, {})
+ process_section(section_config, section_defaults, section_name)
+ else:
+ # Non-dict top-level value
+ validated_config[section_name] = section_config
+
+ # Add missing default values
+ def add_missing_defaults(section_name: str, section_defaults: Dict[str, Any]):
+ if section_name not in validated_config:
+ validated_config[section_name] = {}
+
+ for key, default_value in section_defaults.items():
+ if key not in validated_config[section_name]:
+ validated_config[section_name][key] = default_value
+
+ for section_name, section_defaults in defaults.items():
+ if isinstance(section_defaults, dict):
+ add_missing_defaults(section_name, section_defaults)
+
+ return validated_config, warning_count
+
+
+def load_toml_config(config_path: Optional[Path] = None) -> Tuple[Dict[str, Any], int]:
+ """
+ Load and validate TOML configuration with defaults merging.
+
+ Args:
+ config_path: Path to the configuration file (uses default if None)
+
+ Returns:
+ Tuple of (validated_config, warning_count)
+
+ This function implements T2: TOML loading, default value merging, and validation pipeline.
+ """
+ if config_path is None:
+ config_path = get_user_config_path()
+
+ logger.info(f"Loading configuration from: {config_path}")
+
+ # Get default configuration
+ defaults = get_all_defaults()
+
+ # Load user configuration if file exists
+ user_config = {}
+ if config_path.exists():
+ try:
+ with open(config_path, 'r', encoding='utf-8') as f:
+ user_config = toml.load(f)
+ logger.info(f"Successfully loaded TOML configuration from {config_path}")
+ except toml.TomlDecodeError as e:
+ logger.error(f"Failed to parse TOML configuration from {config_path}: {e}")
+ logger.warning("Using default configuration due to TOML parsing error")
+ user_config = {}
+ except Exception as e:
+ logger.error(f"Failed to read configuration file {config_path}: {e}")
+ logger.warning("Using default configuration due to file read error")
+ user_config = {}
+ else:
+ logger.warning(f"Configuration file {config_path} does not exist, using defaults")
+
+ # Validate and merge configuration
+ validator = ConfigValidator()
+ validated_config, warning_count = validator.validate_and_fix_config(user_config, defaults)
+
+ # Apply consistency checks
+ validated_config = _apply_consistency_checks(validated_config)
+
+ logger.info(f"Configuration loaded successfully with {warning_count} warnings")
+ return validated_config, warning_count
+
+
+def _apply_consistency_checks(config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Apply logical consistency checks to the configuration.
+
+ Args:
+ config: Validated configuration
+
+ Returns:
+ Configuration with consistency fixes applied
+ """
+ # Check heartbeat interval consistency
+ health_config = config.get("health_check", {})
+ normal_interval = health_config.get("normal_heartbeat_interval", 30.0)
+ warning_interval = health_config.get("warning_heartbeat_interval", 10.0)
+
+ if warning_interval >= normal_interval:
+ logger.warning(f"Warning heartbeat interval ({warning_interval}s) should be less than normal interval ({normal_interval}s)")
+ # Fix: set warning interval to half of normal interval
+ fixed_warning_interval = normal_interval / 2
+ health_config["warning_heartbeat_interval"] = fixed_warning_interval
+ logger.info(f"Fixed warning heartbeat interval to {fixed_warning_interval}s")
+ config["health_check"] = health_config
+
+ # Check monitoring vs health check consistency
+ monitoring_config = config.get("monitoring", {})
+ health_check_seconds = monitoring_config.get("health_check_seconds", 30)
+
+ if abs(health_check_seconds - normal_interval) > 5:
+ logger.warning(f"Health check interval mismatch: monitoring.health_check_seconds={health_check_seconds}s, health_check.normal_heartbeat_interval={normal_interval}s")
+ # Use the shorter interval for better responsiveness
+ min_interval = min(health_check_seconds, normal_interval)
+ monitoring_config["health_check_seconds"] = min_interval
+ health_config["normal_heartbeat_interval"] = min_interval
+ logger.info(f"Harmonized health check intervals to {min_interval}s")
+ config["monitoring"] = monitoring_config
+ config["health_check"] = health_config
+
+ # Check response time thresholds consistency
+ healthy_threshold = monitoring_config.get("healthy_response_threshold", 1.0)
+ warning_threshold = monitoring_config.get("warning_response_threshold", 3.0)
+ slow_threshold = monitoring_config.get("slow_response_threshold", 10.0)
+
+ # Ensure healthy < warning < slow
+ if healthy_threshold >= warning_threshold:
+ logger.warning(f"healthy_response_threshold ({healthy_threshold}s) should be less than warning_response_threshold ({warning_threshold}s)")
+ # Fix by making healthy half of warning
+ monitoring_config["healthy_response_threshold"] = min(warning_threshold / 2, 1.0)
+ logger.info(f"Fixed healthy_response_threshold to {monitoring_config['healthy_response_threshold']}s")
+
+ if warning_threshold >= slow_threshold:
+ logger.warning(f"warning_response_threshold ({warning_threshold}s) should be less than slow_response_threshold ({slow_threshold}s)")
+ # Fix by making warning half of slow
+ monitoring_config["warning_response_threshold"] = slow_threshold / 2
+ logger.info(f"Fixed warning_response_threshold to {monitoring_config['warning_response_threshold']}s")
+
+ # Ensure healthy < new warning if we changed it
+ new_warning = monitoring_config["warning_response_threshold"]
+ if monitoring_config["healthy_response_threshold"] >= new_warning:
+ monitoring_config["healthy_response_threshold"] = new_warning / 2
+ logger.info(f"Further fixed healthy_response_threshold to {monitoring_config['healthy_response_threshold']}s")
+
+ config["monitoring"] = monitoring_config
+
+ return config
+
+
+class ConfigFlattener:
+ """Configuration flattening class for T3."""
+
+ @staticmethod
+ def flatten_config(config: Dict[str, Any], prefix: str = "config") -> Dict[str, Any]:
+ """
+ Flatten nested configuration into key-value pairs.
+
+ Args:
+ config: Nested configuration dictionary
+ prefix: Key prefix (default: "config")
+
+ Returns:
+ Flattened key-value pairs with format: "prefix.section.key"
+ """
+ flattened = {}
+
+ def _flatten_recursive(obj: Any, current_path: list[str]) -> None:
+ if isinstance(obj, dict):
+ for key, value in obj.items():
+ new_path = current_path + [key]
+ _flatten_recursive(value, new_path)
+ else:
+ # Create the flattened key
+ key_path = ".".join([prefix] + current_path)
+ flattened[key_path] = obj
+
+ _flatten_recursive(config, [])
+ return flattened
+
+ @staticmethod
+ def create_config_kv_store(config: Optional[Dict[str, Any]] = None) -> Optional['AsyncKeyValue']:
+ """
+ Create a dedicated KV store for configuration using memory backend.
+
+ Args:
+ config: Configuration for the KV store (optional)
+
+ Returns:
+ AsyncKeyValue KV store instance or None if creation fails
+ """
+ try:
+ # Import KV store factory
+ from ..core.registry.kv_store_factory import _build_kv_store
+
+ # Default configuration for config KV store
+ kv_config = {
+ "type": "memory", # Always use memory backend for config
+ "enable_statistics": False, # No statistics needed for config
+ "enable_size_limit": True,
+ "max_item_size": 1024 * 1024, # 1MB per config item
+ "enable_compression": False, # No compression for small config values
+ }
+
+ # Override with user-provided config if available
+ if config:
+ kv_config.update(config)
+
+ logger.info(f"Creating configuration KV store with config: {kv_config}")
+ return _build_kv_store(kv_config)
+
+ except Exception as e:
+ logger.error(f"Failed to create configuration KV store: {e}")
+ return None
+
+ @staticmethod
+ async def write_config_to_kv(config: Dict[str, Any], kv_store: 'AsyncKeyValue',
+ prefix: str = "config") -> bool:
+ """
+ Write configuration to KV store with flattened keys.
+
+ Args:
+ config: Configuration dictionary to write
+ kv_store: KV store instance
+ prefix: Key prefix (default: "config")
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ # Flatten configuration
+ flattened = ConfigFlattener.flatten_config(config, prefix)
+
+ logger.info(f"Writing {len(flattened)} configuration keys to KV store")
+
+ # Write all keys to KV store
+ for key, value in flattened.items():
+ # Respect py-key-value's expectation that values are dicts
+ # by wrapping non-dict values into {"value": actual}.
+ # Dict values (if any) are passed through as-is.
+ if isinstance(value, dict):
+ store_value = value
+ else:
+ store_value = {"value": value}
+
+ await kv_store.put(key, store_value)
+ # logger.debug(f"Put config key: {key} = {store_value}") # Commented out: avoid 80 duplicate logs
+
+ logger.info(f"Successfully wrote {len(flattened)} configuration keys to KV store")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to write configuration to KV store: {e}")
+ return False
+
+ @staticmethod
+ def write_config_to_kv_sync(config: Dict[str, Any], kv_store: 'AsyncKeyValue',
+ prefix: str = "config") -> bool:
+ """
+ Write configuration to KV store synchronously.
+
+ Args:
+ config: Configuration dictionary to write
+ kv_store: KV store instance
+ prefix: Key prefix (default: "config")
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ import asyncio
+
+ # Check if we're already in an async context
+ try:
+ loop = asyncio.get_running_loop()
+ if loop.is_running():
+ # We're in an async context, need to create a new loop
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(
+ asyncio.run,
+ ConfigFlattener.write_config_to_kv(config, kv_store, prefix)
+ )
+ return future.result()
+ except RuntimeError:
+ # No running loop, use asyncio.run directly
+ pass
+
+ # Not in async context, run directly
+ return asyncio.run(ConfigFlattener.write_config_to_kv(config, kv_store, prefix))
+
+ except Exception as e:
+ logger.error(f"Failed to write configuration to KV store (sync): {e}")
+ return False
+
+
+def initialize_config_kv_store(config: Optional[Dict[str, Any]] = None) -> Optional['AsyncKeyValue']:
+ """
+ Initialize the configuration KV store with fallback handling.
+
+ Args:
+ config: Validated configuration dictionary (from T2)
+
+ Returns:
+ AsyncKeyValue KV store instance or None if initialization fails
+ """
+ logger.info("Initializing configuration KV store...")
+
+ # Try to create config KV store
+ kv_store = ConfigFlattener.create_config_kv_store()
+
+ if kv_store is None:
+ logger.error("Failed to create configuration KV store")
+ return None
+
+ # Write configuration to KV store
+ if config:
+ success = ConfigFlattener.write_config_to_kv_sync(config, kv_store)
+ if not success:
+ logger.warning("Failed to write configuration to KV store, but store was created")
+ else:
+ logger.info("Configuration KV store initialized successfully")
+ else:
+ logger.warning("No configuration provided, KV store created but empty")
+
+ return kv_store
+
+
+def initialize_config_system_with_kv(config_path: Optional[Path] = None) -> Tuple[Dict[str, Any], Optional['AsyncKeyValue'], int]:
+ """
+ Complete configuration system initialization including KV storage.
+
+ This function implements the full T1-T3 pipeline:
+ 1. Initialize file system (T1)
+ 2. Load and validate configuration (T2)
+ 3. Initialize KV store and write configuration (T3)
+
+ Args:
+ config_path: Path to configuration file (optional)
+
+ Returns:
+ Tuple of (validated_config, kv_store, warning_count)
+ """
+ logger.info("Initializing complete configuration system with KV storage...")
+
+ # Step 1: Initialize file system (T1)
+ file_system_success = initialize_config_system()
+ if not file_system_success:
+ logger.warning("File system initialization failed, continuing...")
+
+ # Step 2: Load and validate configuration (T2)
+ validated_config, warning_count = load_toml_config(config_path)
+
+ # Step 3: Initialize KV store and write configuration (T3)
+ kv_store = initialize_config_kv_store(validated_config)
+
+ if kv_store is None:
+ logger.error("Configuration KV store initialization failed")
+ return validated_config, None, warning_count
+
+ logger.info(f"Complete configuration system initialized with {warning_count} warnings")
+ return validated_config, kv_store, warning_count
+
+
+# =============================================================================
+# T4: MCPStoreConfig Class and Global Access Entry Points
+# =============================================================================
+
+import asyncio
+from typing import Protocol, runtime_checkable
+from dataclasses import dataclass
+
+# Import existing configuration classes for type conversion
+try:
+ from ..core.lifecycle.config import ServiceLifecycleConfig
+except ImportError as e:
+ logger.warning(f"ServiceLifecycleConfig could not be imported: {e}")
+ ServiceLifecycleConfig = None
+
+try:
+ from .cache_config import MemoryConfig, RedisConfig
+except ImportError as e:
+ logger.warning(f"Cache configuration classes could not be imported: {e}")
+ MemoryConfig = None
+ RedisConfig = None
+
+try:
+ from ..extensions.monitoring.config import MonitoringConfigProcessor
+except ImportError as e:
+ logger.warning(f"MonitoringConfigProcessor could not be imported: {e}")
+ MonitoringConfigProcessor = None
+
+try:
+ from ..core.configuration.standalone_config import StandaloneConfig
+except ImportError as e:
+ logger.warning(f"StandaloneConfig could not be imported: {e}")
+ StandaloneConfig = None
+
+
+@runtime_checkable
+class AsyncKeyValue(Protocol):
+ """Protocol for AsyncKeyValue to avoid circular imports."""
+
+ async def get(self, key: str, default: Any = None) -> Any:
+ """Get value by key."""
+ ...
+
+ async def put(self, key: str, value: Any) -> None:
+ """Put value by key."""
+ ...
+
+ async def delete(self, key: str) -> bool:
+ """Delete value by key."""
+ ...
+
+
+@dataclass
+class MonitoringConfig:
+ """Monitoring configuration dataclass."""
+ health_check_seconds: int = _monitoring_defaults.health_check_seconds
+ tools_update_hours: float = _monitoring_defaults.tools_update_hours
+ reconnection_seconds: int = _monitoring_defaults.reconnection_seconds
+ cleanup_hours: float = _monitoring_defaults.cleanup_hours
+ enable_tools_update: bool = _monitoring_defaults.enable_tools_update
+ enable_reconnection: bool = _monitoring_defaults.enable_reconnection
+ update_tools_on_reconnection: bool = _monitoring_defaults.update_tools_on_reconnection
+ detect_tools_changes: bool = _monitoring_defaults.detect_tools_changes
+ local_service_ping_timeout: int = _monitoring_defaults.local_service_ping_timeout
+ remote_service_ping_timeout: int = _monitoring_defaults.remote_service_ping_timeout
+ startup_wait_time: int = _monitoring_defaults.startup_wait_time
+ healthy_response_threshold: float = _monitoring_defaults.healthy_response_threshold
+ warning_response_threshold: float = _monitoring_defaults.warning_response_threshold
+ slow_response_threshold: float = _monitoring_defaults.slow_response_threshold
+ enable_adaptive_timeout: bool = _monitoring_defaults.enable_adaptive_timeout
+ adaptive_timeout_multiplier: float = _monitoring_defaults.adaptive_timeout_multiplier
+ response_time_history_size: int = _monitoring_defaults.response_time_history_size
+
+
+@dataclass
+class LoggingConfig:
+ """Logging configuration dataclass."""
+ level: str = "INFO"
+ format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ enable_debug: bool = False
+ enable_file_logging: bool = True
+ log_file_path: str = "logs/mcpstore.log"
+
+
+@dataclass
+class ContentUpdateConfig:
+ """Content update configuration dataclass."""
+ tools_update_interval: float = _content_defaults.tools_update_interval
+ resources_update_interval: float = _content_defaults.resources_update_interval
+ prompts_update_interval: float = _content_defaults.prompts_update_interval
+ max_concurrent_updates: int = _content_defaults.max_concurrent_updates
+ update_timeout: float = _content_defaults.update_timeout
+ max_consecutive_failures: int = _content_defaults.max_consecutive_failures
+ failure_backoff_multiplier: float = _content_defaults.failure_backoff_multiplier
+ enable_auto_update: bool = True
+ enable_content_validation: bool = True
+
+
+class MCPStoreConfig:
+ """
+ MCPStore Configuration Class - Central configuration access point.
+
+ This class serves as the unified entry point for all configuration operations,
+ reading from KV storage and assembling strongly-typed configuration objects.
+ """
+
+ def __init__(self, kv_store: AsyncKeyValue, namespace: str = "config"):
+ """
+ Initialize MCPStoreConfig.
+
+ Args:
+ kv_store: AsyncKeyValue store instance containing configuration
+ namespace: Configuration namespace prefix (default: "config")
+ """
+ self._kv = kv_store
+ self._namespace = namespace
+ self._cache = {} # Simple in-memory cache for frequently accessed configs
+ self._cache_enabled = True
+
+ logger.info(f"MCPStoreConfig initialized with namespace: {namespace}")
+
+ async def _get_config_value(self, key: str, default: Any = None) -> Any:
+ """
+ Get a configuration value from KV store with optional caching.
+
+ Args:
+ key: Configuration key (without namespace prefix)
+ default: Default value if key not found
+
+ Returns:
+ Configuration value or default
+ """
+ full_key = f"{self._namespace}.{key}"
+
+ # Check cache first if enabled
+ if self._cache_enabled and full_key in self._cache:
+ logger.debug(f"Cache hit for config key: {full_key}")
+ return self._cache[full_key]
+
+ try:
+ # NOTE: py-key-value's get() method does NOT accept a 'default' parameter.
+ # It only accepts 'key' (positional) and 'collection' (keyword-only).
+ # When a key is not found, it returns None.
+ raw_value = await self._kv.get(key=full_key)
+
+ # If key not found, return the default value
+ if raw_value is None:
+ logger.debug(f"Config key not found: {full_key}, using default: {default}")
+ return default
+
+ logger.debug(f"Retrieved config key: {full_key} = {raw_value}")
+
+ # Unwrap values stored as {"value": actual} by the config
+ # flattener, while leaving normal dict config objects untouched.
+ if isinstance(raw_value, dict) and "value" in raw_value and len(raw_value) == 1:
+ value = raw_value["value"]
+ else:
+ value = raw_value
+
+ # Cache the value if caching is enabled
+ if self._cache_enabled:
+ self._cache[full_key] = value
+
+ return value
+
+ except Exception as e:
+ logger.error(f"Failed to get config value for key {full_key}: {e}")
+ return default
+
+ async def get_lifecycle_config(self) -> ServiceLifecycleConfig:
+ """
+ Get service lifecycle configuration.
+
+ Returns:
+ ServiceLifecycleConfig: Lifecycle configuration object
+ """
+ if ServiceLifecycleConfig is None:
+ # Fallback if import failed
+ logger.warning("ServiceLifecycleConfig not available, returning dict")
+ return {
+ "warning_failure_threshold": await self._get_config_value("health_check.warning_failure_threshold", 1),
+ "reconnecting_failure_threshold": await self._get_config_value("health_check.reconnecting_failure_threshold", 2),
+ "max_reconnect_attempts": await self._get_config_value("health_check.max_reconnect_attempts", 10),
+ "base_reconnect_delay": await self._get_config_value("health_check.base_reconnect_delay", 1.0),
+ "max_reconnect_delay": await self._get_config_value("health_check.max_reconnect_delay", 60.0),
+ "long_retry_interval": await self._get_config_value("health_check.long_retry_interval", 300.0),
+ "normal_heartbeat_interval": await self._get_config_value("health_check.normal_heartbeat_interval", 30.0),
+ "warning_heartbeat_interval": await self._get_config_value("health_check.warning_heartbeat_interval", 10.0),
+ "health_check_ping_timeout": await self._get_config_value("health_check.health_check_ping_timeout", 10.0),
+ "initialization_timeout": await self._get_config_value("health_check.initialization_timeout", 300.0),
+ "disconnection_timeout": await self._get_config_value("health_check.disconnection_timeout", 10.0),
+ }
+
+ # Create ServiceLifecycleConfig from KV values
+ return ServiceLifecycleConfig(
+ warning_failure_threshold=await self._get_config_value("health_check.warning_failure_threshold", 1),
+ reconnecting_failure_threshold=await self._get_config_value("health_check.reconnecting_failure_threshold", 2),
+ max_reconnect_attempts=await self._get_config_value("health_check.max_reconnect_attempts", 10),
+ base_reconnect_delay=await self._get_config_value("health_check.base_reconnect_delay", 1.0),
+ max_reconnect_delay=await self._get_config_value("health_check.max_reconnect_delay", 60.0),
+ long_retry_interval=await self._get_config_value("health_check.long_retry_interval", 300.0),
+ normal_heartbeat_interval=await self._get_config_value("health_check.normal_heartbeat_interval", 30.0),
+ warning_heartbeat_interval=await self._get_config_value("health_check.warning_heartbeat_interval", 10.0),
+ health_check_ping_timeout=await self._get_config_value("health_check.health_check_ping_timeout", 10.0),
+ initialization_timeout=await self._get_config_value("health_check.initialization_timeout", 300.0),
+ disconnection_timeout=await self._get_config_value("health_check.disconnection_timeout", 10.0),
+ )
+
+ async def get_cache_memory_config(self) -> MemoryConfig:
+ """
+ Get memory cache configuration.
+
+ Returns:
+ MemoryConfig: Memory cache configuration object
+ """
+ if MemoryConfig is None:
+ # Fallback if import failed
+ logger.warning("MemoryConfig not available, returning dict")
+ return {
+ "timeout": await self._get_config_value("cache.memory.timeout", 2.0),
+ "retry_attempts": await self._get_config_value("cache.memory.retry_attempts", 3),
+ "health_check": await self._get_config_value("cache.memory.health_check", True),
+ "max_size": await self._get_config_value("cache.memory.max_size", None),
+ "cleanup_interval": await self._get_config_value("cache.memory.cleanup_interval", 300),
+ }
+
+ return MemoryConfig(
+ timeout=await self._get_config_value("cache.memory.timeout", 2.0),
+ retry_attempts=await self._get_config_value("cache.memory.retry_attempts", 3),
+ health_check=await self._get_config_value("cache.memory.health_check", True),
+ max_size=await self._get_config_value("cache.memory.max_size", None),
+ cleanup_interval=await self._get_config_value("cache.memory.cleanup_interval", 300),
+ )
+
+ async def get_cache_redis_config(self) -> RedisConfig:
+ """
+ Get Redis cache configuration (non-sensitive parts only).
+
+ Returns:
+ RedisConfig: Redis cache configuration object (without sensitive data)
+ """
+ if RedisConfig is None:
+ # Fallback if import failed
+ logger.warning("RedisConfig not available, returning dict")
+ return {
+ "timeout": await self._get_config_value("cache.redis.timeout", 2.0),
+ "retry_attempts": await self._get_config_value("cache.redis.retry_attempts", 3),
+ "health_check": await self._get_config_value("cache.redis.health_check", True),
+ "max_connections": await self._get_config_value("cache.redis.max_connections", 50),
+ "retry_on_timeout": await self._get_config_value("cache.redis.retry_on_timeout", True),
+ "socket_keepalive": await self._get_config_value("cache.redis.socket_keepalive", True),
+ "socket_connect_timeout": await self._get_config_value("cache.redis.socket_connect_timeout", 5.0),
+ "socket_timeout": await self._get_config_value("cache.redis.socket_timeout", 5.0),
+ "health_check_interval": await self._get_config_value("cache.redis.health_check_interval", 30),
+ }
+
+ return RedisConfig(
+ timeout=await self._get_config_value("cache.redis.timeout", 2.0),
+ retry_attempts=await self._get_config_value("cache.redis.retry_attempts", 3),
+ health_check=await self._get_config_value("cache.redis.health_check", True),
+ max_connections=await self._get_config_value("cache.redis.max_connections", 50),
+ retry_on_timeout=await self._get_config_value("cache.redis.retry_on_timeout", True),
+ socket_keepalive=await self._get_config_value("cache.redis.socket_keepalive", True),
+ socket_connect_timeout=await self._get_config_value("cache.redis.socket_connect_timeout", 5.0),
+ socket_timeout=await self._get_config_value("cache.redis.socket_timeout", 5.0),
+ health_check_interval=await self._get_config_value("cache.redis.health_check_interval", 30),
+ allow_partial=True, # Allow partial config for non-sensitive fields only
+ )
+
+ async def get_monitoring_config(self) -> MonitoringConfig:
+ """
+ Get monitoring configuration.
+
+ Returns:
+ MonitoringConfig: Monitoring configuration object
+ """
+ return MonitoringConfig(
+ health_check_seconds=await self._get_config_value("monitoring.health_check_seconds", 30),
+ tools_update_hours=await self._get_config_value("monitoring.tools_update_hours", 2),
+ reconnection_seconds=await self._get_config_value("monitoring.reconnection_seconds", 60),
+ cleanup_hours=await self._get_config_value("monitoring.cleanup_hours", 24),
+ enable_tools_update=await self._get_config_value("monitoring.enable_tools_update", True),
+ enable_reconnection=await self._get_config_value("monitoring.enable_reconnection", True),
+ update_tools_on_reconnection=await self._get_config_value("monitoring.update_tools_on_reconnection", True),
+ detect_tools_changes=await self._get_config_value("monitoring.detect_tools_changes", False),
+ local_service_ping_timeout=await self._get_config_value("monitoring.local_service_ping_timeout", 3),
+ remote_service_ping_timeout=await self._get_config_value("monitoring.remote_service_ping_timeout", 5),
+ startup_wait_time=await self._get_config_value("monitoring.startup_wait_time", 2),
+ healthy_response_threshold=await self._get_config_value("monitoring.healthy_response_threshold", 1.0),
+ warning_response_threshold=await self._get_config_value("monitoring.warning_response_threshold", 3.0),
+ slow_response_threshold=await self._get_config_value("monitoring.slow_response_threshold", 10.0),
+ enable_adaptive_timeout=await self._get_config_value("monitoring.enable_adaptive_timeout", True),
+ adaptive_timeout_multiplier=await self._get_config_value("monitoring.adaptive_timeout_multiplier", 2.0),
+ response_time_history_size=await self._get_config_value("monitoring.response_time_history_size", 10),
+ )
+
+ async def get_content_update_config(self) -> ContentUpdateConfig:
+ """
+ Get content update configuration.
+
+ Returns:
+ ContentUpdateConfig: Content update configuration object
+ """
+ return ContentUpdateConfig(
+ tools_update_interval=await self._get_config_value("content_update.tools_update_interval", 300.0),
+ resources_update_interval=await self._get_config_value("content_update.resources_update_interval", 600.0),
+ prompts_update_interval=await self._get_config_value("content_update.prompts_update_interval", 600.0),
+ max_concurrent_updates=await self._get_config_value("content_update.max_concurrent_updates", 3),
+ update_timeout=await self._get_config_value("content_update.update_timeout", 30.0),
+ max_consecutive_failures=await self._get_config_value("content_update.max_consecutive_failures", 3),
+ failure_backoff_multiplier=await self._get_config_value("content_update.failure_backoff_multiplier", 2.0),
+ enable_auto_update=await self._get_config_value("content_update.enable_auto_update", True),
+ enable_content_validation=await self._get_config_value("content_update.enable_content_validation", True),
+ )
+
+ async def get_logging_config(self) -> LoggingConfig:
+ """
+ Get logging configuration.
+
+ Returns:
+ LoggingConfig: Logging configuration object
+ """
+ return LoggingConfig(
+ level=await self._get_config_value("logging.level", "INFO"),
+ format=await self._get_config_value("logging.format", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
+ enable_debug=await self._get_config_value("logging.enable_debug", False),
+ enable_file_logging=await self._get_config_value("logging.enable_file_logging", True),
+ log_file_path=await self._get_config_value("logging.log_file_path", "logs/mcpstore.log"),
+ )
+
+ async def get_standalone_config(self):
+ """
+ Get standalone configuration.
+
+ Returns:
+ StandaloneConfig or dict: Standalone configuration object
+ """
+ if StandaloneConfig is None:
+ # Fallback if import failed
+ logger.warning("StandaloneConfig not available, returning dict")
+ return {
+ "heartbeat_interval_seconds": await self._get_config_value("standalone.heartbeat_interval_seconds", 60),
+ "http_timeout_seconds": await self._get_config_value("standalone.http_timeout_seconds", 30),
+ "reconnection_interval_seconds": await self._get_config_value("standalone.reconnection_interval_seconds", 300),
+ "cleanup_interval_seconds": await self._get_config_value("standalone.cleanup_interval_seconds", 3600),
+ "streamable_http_endpoint": await self._get_config_value("standalone.streamable_http_endpoint", "/mcp"),
+ "default_transport": await self._get_config_value("standalone.default_transport", "http"),
+ "log_level": await self._get_config_value("standalone.log_level", "INFO"),
+ "enable_debug": await self._get_config_value("standalone.enable_debug", False),
+ }
+
+ if StandaloneConfig is None:
+ # Fallback if import failed
+ return {
+ "heartbeat_interval_seconds": await self._get_config_value("standalone.heartbeat_interval_seconds", 60),
+ "http_timeout_seconds": await self._get_config_value("standalone.http_timeout_seconds", 30),
+ "reconnection_interval_seconds": await self._get_config_value("standalone.reconnection_interval_seconds", 300),
+ "cleanup_interval_seconds": await self._get_config_value("standalone.cleanup_interval_seconds", 3600),
+ "streamable_http_endpoint": await self._get_config_value("standalone.streamable_http_endpoint", "/mcp"),
+ "default_transport": await self._get_config_value("standalone.default_transport", "http"),
+ "log_level": await self._get_config_value("standalone.log_level", "INFO"),
+ "enable_debug": await self._get_config_value("standalone.enable_debug", False),
+ }
+
+ return StandaloneConfig(
+ heartbeat_interval_seconds=await self._get_config_value("standalone.heartbeat_interval_seconds", 60),
+ http_timeout_seconds=await self._get_config_value("standalone.http_timeout_seconds", 30),
+ reconnection_interval_seconds=await self._get_config_value("standalone.reconnection_interval_seconds", 300),
+ cleanup_interval_seconds=await self._get_config_value("standalone.cleanup_interval_seconds", 3600),
+ streamable_http_endpoint=await self._get_config_value("standalone.streamable_http_endpoint", "/mcp"),
+ default_transport=await self._get_config_value("standalone.default_transport", "http"),
+ log_level=await self._get_config_value("standalone.log_level", "INFO"),
+ enable_debug=await self._get_config_value("standalone.enable_debug", False),
+ )
+
+ async def get_server_config(self) -> Dict[str, Any]:
+ """
+ Get API server configuration.
+
+ Returns:
+ Dict containing server configuration
+ """
+ return {
+ "host": await self._get_config_value("server.host", "0.0.0.0"),
+ "port": await self._get_config_value("server.port", 18200),
+ "reload": await self._get_config_value("server.reload", False),
+ "auto_open_browser": await self._get_config_value("server.auto_open_browser", False),
+ "show_startup_info": await self._get_config_value("server.show_startup_info", True),
+ "log_level": await self._get_config_value("server.log_level", "info"),
+ "url_prefix": await self._get_config_value("server.url_prefix", ""),
+ }
+
+ async def get_tool_set_config_async(self) -> Dict[str, Any]:
+ """
+ 获取工具集配置
+
+ Returns:
+ Dict: 工具集配置字典,包含以下键:
+ - enable_tool_set: 是否启用工具集管理功能
+ - cache_ttl_seconds: 缓存过期时间(秒)
+ - max_tools_per_service: 每个服务的最大工具数量
+ """
+ return {
+ "enable_tool_set": await self._get_config_value("tool_set.enable_tool_set", True),
+ "cache_ttl_seconds": await self._get_config_value("tool_set.cache_ttl_seconds", 3600),
+ "max_tools_per_service": await self._get_config_value("tool_set.max_tools_per_service", 1000),
+ }
+
+ async def get_raw_config_section(self, section: str) -> Dict[str, Any]:
+ """
+ Get a raw configuration section as dictionary.
+
+ Args:
+ section: Configuration section name (e.g., "server", "cache")
+
+ Returns:
+ Dict containing the configuration section
+ """
+ section_config = {}
+ prefix = f"{self._namespace}.{section}"
+
+ # Get all keys in the section
+ try:
+ # This is a simplified implementation - in a real scenario,
+ # you might want to add a method to list all keys in the KV store
+ # For now, we'll construct the section from known keys
+
+ if section == "health_check":
+ section_config = {
+ "warning_failure_threshold": await self._get_config_value("health_check.warning_failure_threshold", 1),
+ "reconnecting_failure_threshold": await self._get_config_value("health_check.reconnecting_failure_threshold", 2),
+ "max_reconnect_attempts": await self._get_config_value("health_check.max_reconnect_attempts", 10),
+ "base_reconnect_delay": await self._get_config_value("health_check.base_reconnect_delay", 1.0),
+ "max_reconnect_delay": await self._get_config_value("health_check.max_reconnect_delay", 60.0),
+ "long_retry_interval": await self._get_config_value("health_check.long_retry_interval", 300.0),
+ "normal_heartbeat_interval": await self._get_config_value("health_check.normal_heartbeat_interval", 30.0),
+ "warning_heartbeat_interval": await self._get_config_value("health_check.warning_heartbeat_interval", 10.0),
+ "health_check_ping_timeout": await self._get_config_value("health_check.health_check_ping_timeout", 10.0),
+ "initialization_timeout": await self._get_config_value("health_check.initialization_timeout", 300.0),
+ "disconnection_timeout": await self._get_config_value("health_check.disconnection_timeout", 10.0),
+ }
+ elif section == "monitoring":
+ section_config = {
+ "health_check_seconds": await self._get_config_value("monitoring.health_check_seconds", 30),
+ "tools_update_hours": await self._get_config_value("monitoring.tools_update_hours", 2),
+ "reconnection_seconds": await self._get_config_value("monitoring.reconnection_seconds", 60),
+ "cleanup_hours": await self._get_config_value("monitoring.cleanup_hours", 24),
+ "enable_tools_update": await self._get_config_value("monitoring.enable_tools_update", True),
+ "enable_reconnection": await self._get_config_value("monitoring.enable_reconnection", True),
+ "update_tools_on_reconnection": await self._get_config_value("monitoring.update_tools_on_reconnection", True),
+ "detect_tools_changes": await self._get_config_value("monitoring.detect_tools_changes", False),
+ }
+ # Add more sections as needed
+
+ logger.debug(f"Retrieved raw config section '{section}' with {len(section_config)} keys")
+ return section_config
+
+ except Exception as e:
+ logger.error(f"Failed to get raw config section '{section}': {e}")
+ return {}
+
+ def clear_cache(self):
+ """Clear the internal configuration cache."""
+ self._cache.clear()
+ logger.debug("Configuration cache cleared")
+
+ def enable_caching(self, enabled: bool = True):
+ """
+ Enable or disable configuration caching.
+
+ Args:
+ enabled: Whether to enable caching (default: True)
+ """
+ self._cache_enabled = enabled
+ if not enabled:
+ self._cache.clear()
+ logger.info(f"Configuration caching {'enabled' if enabled else 'disabled'}")
+
+ async def get_lifecycle_config_sync(self) -> 'ServiceLifecycleConfig':
+ """
+ Get service lifecycle configuration (synchronous fallback).
+
+ This method attempts to get lifecycle configuration synchronously.
+ If async operations are not available, it falls back to defaults
+ but logs the situation for debugging.
+
+ Returns:
+ ServiceLifecycleConfig: Lifecycle configuration object
+ """
+ try:
+ # Try async approach first
+ return await self.get_lifecycle_config()
+ except Exception as e:
+ # Fall back to default configuration
+ logger.warning(f"Async config retrieval failed: {e}, using default lifecycle config")
+ if ServiceLifecycleConfig is None:
+ return {}
+ return ServiceLifecycleConfig()
+
+ # T11: Configuration snapshot and debugging observability
+ async def generate_config_snapshot(self,
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = True) -> 'ConfigSnapshot':
+ """
+ 生成配置快照
+
+ Args:
+ categories: 要包含的配置分类列表
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+
+ Returns:
+ ConfigSnapshot: 配置快照对象
+ """
+ from .core.configuration.config_snapshot_generator import ConfigSnapshotGenerator
+ generator = ConfigSnapshotGenerator(self)
+ return await generator.generate_snapshot(
+ categories=categories,
+ key_pattern=key_pattern,
+ include_sensitive=include_sensitive
+ )
+
+ async def export_config_snapshot(self,
+ format: str = "table",
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = False,
+ output_file: Optional[Union[str, Path]] = None,
+ mask_sensitive: bool = True) -> str:
+ """
+ 导出配置快照
+
+ Args:
+ format: 输出格式 ("json", "yaml", "table")
+ categories: 要包含的配置分类列表
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+ output_file: 输出文件路径,None 表示返回字符串
+ mask_sensitive: 是否屏蔽敏感配置值
+
+ Returns:
+ str: 配置快照内容或文件路径
+ """
+ from .core.configuration.config_export_service import ConfigExportService
+ export_service = ConfigExportService()
+ return await export_service.export_config(
+ format=format,
+ categories=categories,
+ key_pattern=key_pattern,
+ include_sensitive=include_sensitive,
+ output_file=output_file,
+ mask_sensitive=mask_sensitive
+ )
+
+ async def get_config_summary(self) -> Dict[str, Any]:
+ """
+ 获取配置摘要信息
+
+ Returns:
+ Dict[str, Any]: 配置摘要
+ """
+ from .core.configuration.config_export_service import ConfigExportService
+ export_service = ConfigExportService()
+ return await export_service.get_config_summary()
+
+
+# Global configuration instance
+_global_config: Optional[MCPStoreConfig] = None
+_config_lock = asyncio.Lock()
+
+
+async def init_config(config_path: Optional[Path] = None) -> MCPStoreConfig:
+ """
+ Initialize the global configuration system.
+
+ This function completes the full T1-T3 pipeline and creates the global MCPStoreConfig instance:
+ 1. Initialize file system (T1)
+ 2. Load and validate configuration (T2)
+ 3. Initialize KV store and write configuration (T3)
+ 4. Create and return MCPStoreConfig instance (T4)
+
+ Args:
+ config_path: Path to configuration file (optional)
+
+ Returns:
+ MCPStoreConfig: The global configuration instance
+
+ Raises:
+ RuntimeError: If configuration initialization fails
+ """
+ global _global_config
+
+ async with _config_lock:
+ if _global_config is not None:
+ logger.info("Configuration already initialized, returning existing instance")
+ return _global_config
+
+ logger.info("Initializing global configuration system...")
+
+ # Complete T1-T3 pipeline
+ validated_config, kv_store, warning_count = initialize_config_system_with_kv(config_path)
+
+ if kv_store is None:
+ raise RuntimeError("Failed to initialize configuration KV store")
+
+ # Create MCPStoreConfig instance (T4)
+ _global_config = MCPStoreConfig(kv_store)
+
+ logger.info(f"Global configuration system initialized with {warning_count} warnings")
+ return _global_config
+
+
+def get_config() -> Optional[MCPStoreConfig]:
+ """
+ Get the global configuration instance.
+
+ Returns:
+ MCPStoreConfig: The global configuration instance, or None if not initialized
+
+ Raises:
+ RuntimeError: If configuration has not been initialized
+ """
+ if _global_config is None:
+ logger.warning("Configuration not initialized. Call init_config() first.")
+ return None
+
+ return _global_config
+
+
+async def get_config_async() -> Optional[MCPStoreConfig]:
+ """
+ Get the global configuration instance (async version).
+
+ Returns:
+ MCPStoreConfig: The global configuration instance, or None if not initialized
+ """
+ async with _config_lock:
+ return _global_config
+
+
+def is_config_initialized() -> bool:
+ """
+ Check if the global configuration has been initialized.
+
+ Returns:
+ bool: True if configuration is initialized, False otherwise
+ """
+ return _global_config is not None
+
+
+async def shutdown_config():
+ """Shutdown the global configuration system."""
+ global _global_config
+
+ async with _config_lock:
+ if _global_config is not None:
+ logger.info("Shutting down global configuration system...")
+ _global_config.clear_cache()
+ _global_config = None
+ logger.info("Global configuration system shutdown complete")
+
+
+def get_lifecycle_config_with_defaults() -> 'ServiceLifecycleConfig':
+ """Get lifecycle configuration.
+
+ This helper attempts to load from MCPStoreConfig. If called in an async context
+ or if config is not available, returns default ServiceLifecycleConfig.
+ """
+ config = get_config()
+ if config is None:
+ # Config not initialized, return defaults
+ logger.warning("MCPStoreConfig not initialized, using default ServiceLifecycleConfig")
+ try:
+ from mcpstore.core.lifecycle.config import ServiceLifecycleConfig
+ return ServiceLifecycleConfig()
+ except ImportError:
+ logger.error("Cannot import ServiceLifecycleConfig, returning empty dict")
+ return {}
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ # In async context, cannot use asyncio.run - return defaults
+ logger.warning("Cannot load config in async context, using default ServiceLifecycleConfig")
+ try:
+ from mcpstore.core.lifecycle.config import ServiceLifecycleConfig
+ return ServiceLifecycleConfig()
+ except ImportError:
+ return {}
+ except RuntimeError:
+ # No running loop, safe to use asyncio.run
+ try:
+ return asyncio.run(config.get_lifecycle_config())
+ except Exception as e:
+ logger.warning(f"Failed to load lifecycle config: {e}, using defaults")
+ try:
+ from mcpstore.core.lifecycle.config import ServiceLifecycleConfig
+ return ServiceLifecycleConfig()
+ except ImportError:
+ return {}
+
+
+def get_content_update_config_with_defaults() -> ContentUpdateConfig:
+ """Get content update configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to defaults if in async context
+ or if config is unavailable.
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using default ContentUpdateConfig")
+ return ContentUpdateConfig()
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ # In async context, return defaults
+ logger.warning("Cannot load config in async context, using default ContentUpdateConfig")
+ return ContentUpdateConfig()
+ except RuntimeError:
+ # No running loop, safe to use asyncio.run
+ try:
+ return asyncio.run(config.get_content_update_config())
+ except Exception as e:
+ logger.warning(f"Failed to load content update config: {e}, using defaults")
+ return ContentUpdateConfig()
+
+
+def get_monitoring_config_with_defaults() -> MonitoringConfig:
+ """Get monitoring configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to defaults if in async context
+ or if config is unavailable.
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using default MonitoringConfig")
+ return MonitoringConfig()
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ logger.warning("Cannot load config in async context, using default MonitoringConfig")
+ return MonitoringConfig()
+ except RuntimeError:
+ try:
+ return asyncio.run(config.get_monitoring_config())
+ except Exception as e:
+ logger.warning(f"Failed to load monitoring config: {e}, using defaults")
+ return MonitoringConfig()
+
+
+def get_cache_memory_config_with_defaults() -> MemoryConfig:
+ """Get memory cache configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to defaults if in async context
+ or if config is unavailable.
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using default MemoryConfig")
+ return MemoryConfig()
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ logger.warning("Cannot load config in async context, using default MemoryConfig")
+ return MemoryConfig()
+ except RuntimeError:
+ try:
+ return asyncio.run(config.get_cache_memory_config())
+ except Exception as e:
+ logger.warning(f"Failed to load memory config: {e}, using defaults")
+ return MemoryConfig()
+
+
+def get_cache_redis_config_with_defaults() -> RedisConfig:
+ """Get Redis cache configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to defaults if in async context
+ or if config is unavailable.
+
+ Returns:
+ RedisConfig: Redis cache configuration object (non-sensitive fields only)
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using default RedisConfig")
+ return RedisConfig()
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ logger.warning("Cannot load config in async context, using default RedisConfig")
+ return RedisConfig()
+ except RuntimeError:
+ try:
+ return asyncio.run(config.get_cache_redis_config())
+ except Exception as e:
+ logger.warning(f"Failed to load redis config: {e}, using defaults")
+ return RedisConfig()
+
+
+def get_standalone_config_with_defaults() -> Union[Any, Dict[str, Any]]:
+ """Get standalone configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to empty dict if in async context
+ or if config is unavailable.
+
+ Returns:
+ StandaloneConfig or dict with standalone configuration values
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using empty dict for standalone config")
+ return {}
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ # In async context, return empty dict
+ logger.warning("Cannot load config in async context, using empty dict for standalone config")
+ return {}
+ except RuntimeError:
+ # No running loop, safe to use asyncio.run
+ try:
+ return asyncio.run(config.get_standalone_config())
+ except Exception as e:
+ logger.warning(f"Failed to load standalone config: {e}, using empty dict")
+ return {}
+
+
+def get_server_config_with_defaults() -> Dict[str, Any]:
+ """Get API server configuration.
+
+ Attempts to load from MCPStoreConfig. Falls back to empty dict if in async context
+ or if config is unavailable.
+
+ Returns:
+ Dict with server configuration values
+ """
+ config = get_config()
+ if config is None:
+ logger.warning("MCPStoreConfig not initialized, using empty dict for server config")
+ return {}
+
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ logger.warning("Cannot load config in async context, using empty dict for server config")
+ return {}
+ except RuntimeError:
+ # No running loop, safe to use asyncio.run
+ try:
+ return asyncio.run(config.get_server_config())
+ except Exception as e:
+ logger.warning(f"Failed to load server config: {e}, using empty dict")
+ return {}
+
+
+# Export the main initialization function and new classes
+__all__ = [
+ 'ensure_config_directory',
+ 'get_default_config_template',
+ 'create_default_config_if_not_exists',
+ 'initialize_config_system',
+ 'get_user_config_path',
+ 'ConfigValidator',
+ 'load_toml_config',
+ '_apply_consistency_checks',
+ 'ConfigFlattener',
+ 'initialize_config_kv_store',
+ 'initialize_config_system_with_kv',
+ # T4 exports
+ 'MCPStoreConfig',
+ 'AsyncKeyValue',
+ 'MonitoringConfig',
+ 'LoggingConfig',
+ 'ContentUpdateConfig',
+ 'init_config',
+ 'get_config',
+ 'get_config_async',
+ 'is_config_initialized',
+ 'shutdown_config',
+ # T5/T6/T7/T8/T9 helper exports
+ 'get_lifecycle_config_with_defaults',
+ 'get_content_update_config_with_defaults',
+ 'get_monitoring_config_with_defaults',
+ 'get_cache_memory_config_with_defaults',
+ 'get_cache_redis_config_with_defaults',
+ 'get_standalone_config_with_defaults',
+ 'get_server_config_with_defaults',
+]
diff --git a/src/mcpstore/core/agents/__init__.py b/src/mcpstore/core/agents/__init__.py
new file mode 100644
index 00000000..34e0b484
--- /dev/null
+++ b/src/mcpstore/core/agents/__init__.py
@@ -0,0 +1,11 @@
+"""
+MCPStore Agents Package
+Agent-related functionality and management
+
+This package contains Agent-specific components:
+- session_manager: Agent session and state management
+"""
+
+from .session_manager import SessionManager, AgentSession
+
+__all__ = ['SessionManager', 'AgentSession']
diff --git a/src/mcpstore/core/agents/session_manager.py b/src/mcpstore/core/agents/session_manager.py
new file mode 100644
index 00000000..8afdaa4a
--- /dev/null
+++ b/src/mcpstore/core/agents/session_manager.py
@@ -0,0 +1,450 @@
+import logging
+import uuid
+from datetime import datetime, timedelta
+from typing import Dict, Any, Optional
+
+from fastmcp import Client
+
+logger = logging.getLogger(__name__)
+
+class AgentSession:
+ """Agent session class"""
+ def __init__(self, agent_id: str):
+ self.agent_id = agent_id
+ self.services: Dict[str, Client] = {} # service_name -> Client
+ self.tools: Dict[str, Dict[str, Any]] = {} # tool_name -> tool_info
+ self.last_active = datetime.now()
+ self.created_at = datetime.now()
+
+ def update_activity(self):
+ """Update last activity time"""
+ self.last_active = datetime.now()
+
+ def add_service(self, service_name: str, client: Client):
+ """Add service"""
+ self.services[service_name] = client
+
+ def add_tool(self, tool_name: str, tool_info: Dict[str, Any], service_name: str):
+ """Add tool"""
+ self.tools[tool_name] = {
+ **tool_info,
+ "service_name": service_name
+ }
+
+ def get_service_for_tool(self, tool_name: str) -> Optional[str]:
+ """Get service name corresponding to tool"""
+ return self.tools.get(tool_name, {}).get("service_name")
+
+ def get_all_tools(self) -> Dict[str, Dict[str, Any]]:
+ """Get all tool information"""
+ return self.tools
+
+class SessionManager:
+ """
+ Enhanced Session manager with multi-session and cross-context support
+
+ This enhanced version maintains full backward compatibility while adding:
+ - Multiple named sessions per agent
+ - User-defined session IDs with cross-context access
+ - Session mapping and discovery capabilities
+ """
+ def __init__(self, session_timeout: int = 3600):
+ # [LEGACY] Original storage (backward compatibility)
+ self.sessions: Dict[str, AgentSession] = {}
+ self.session_timeout = timedelta(seconds=session_timeout)
+
+ # [NEW] Enhanced storage for multi-session support
+ # Format: {agent_id: {session_name: AgentSession}}
+ self.named_sessions: Dict[str, Dict[str, AgentSession]] = {}
+
+ # [NEW] User session mapping for cross-context access
+ # Format: {user_session_id: (agent_id, session_name)}
+ self.user_session_mapping: Dict[str, tuple[str, str]] = {}
+
+ # [NEW] Global session registry for cross-context discovery
+ # Format: {global_session_id: (agent_id, session_name)}
+ self.global_session_registry: Dict[str, tuple[str, str]] = {}
+
+ def create_session(self, agent_id: Optional[str] = None) -> AgentSession:
+ """Create new session"""
+ if not agent_id:
+ agent_id = str(uuid.uuid4())
+
+ session = AgentSession(agent_id)
+ self.sessions[agent_id] = session
+ logger.info(f"Created new session for agent {agent_id}")
+ return session
+
+ def get_session(self, agent_id: str) -> Optional[AgentSession]:
+ """Get session"""
+ session = self.sessions.get(agent_id)
+ if session:
+ # 检查会话是否过期
+ if datetime.now() - session.last_active > self.session_timeout:
+ logger.info(f"Session expired for agent {agent_id}")
+ del self.sessions[agent_id]
+ return None
+ session.update_activity()
+ return session
+
+ def get_or_create_session(self, agent_id: Optional[str] = None) -> AgentSession:
+ """获取或创建会话"""
+ if agent_id and (session := self.get_session(agent_id)):
+ return session
+ return self.create_session(agent_id)
+
+ def cleanup_expired_sessions(self):
+ """清理过期会话"""
+ now = datetime.now()
+ expired = [
+ agent_id for agent_id, session in self.sessions.items()
+ if now - session.last_active > self.session_timeout
+ ]
+ for agent_id in expired:
+ del self.sessions[agent_id]
+ logger.info(f"Cleaned up expired session for agent {agent_id}")
+
+ # === Enhanced Multi-Session Support ===
+
+ def create_named_session(self, agent_id: str, session_name: str, user_session_id: Optional[str] = None) -> AgentSession:
+ """
+ Create a named session for an agent
+
+ This allows multiple sessions per agent, each with a unique name.
+
+ Args:
+ agent_id: Agent identifier
+ session_name: Unique session name within the agent's scope
+ user_session_id: Optional user-defined session ID for cross-context access
+
+ Returns:
+ AgentSession: Created session object
+
+ Example:
+ # Create multiple sessions for the same agent
+ browser_session = session_manager.create_named_session("team_1", "browser_work")
+ api_session = session_manager.create_named_session("team_1", "api_calls")
+ """
+ try:
+ # Initialize agent's session dictionary if not exists
+ if agent_id not in self.named_sessions:
+ self.named_sessions[agent_id] = {}
+
+ # Check if session name already exists for this agent
+ if session_name in self.named_sessions[agent_id]:
+ logger.warning(f"Session '{session_name}' already exists for agent '{agent_id}', returning existing session")
+ return self.named_sessions[agent_id][session_name]
+
+ # Create new AgentSession
+ session = AgentSession(agent_id)
+
+ # Store in named sessions
+ self.named_sessions[agent_id][session_name] = session
+
+ # Register user session mapping if provided
+ if user_session_id:
+ if user_session_id in self.user_session_mapping:
+ logger.warning(f"User session ID '{user_session_id}' already exists, overwriting")
+ self.user_session_mapping[user_session_id] = (agent_id, session_name)
+
+ # Also register in global registry
+ self.global_session_registry[user_session_id] = (agent_id, session_name)
+
+ logger.info(f"Created named session '{session_name}' for agent '{agent_id}'" +
+ (f" with user session ID '{user_session_id}'" if user_session_id else ""))
+ return session
+
+ except Exception as e:
+ logger.error(f"Failed to create named session '{session_name}' for agent '{agent_id}': {e}")
+ raise
+
+ def get_named_session(self, agent_id: str, session_name: str) -> Optional[AgentSession]:
+ """
+ Get a named session for an agent
+
+ Args:
+ agent_id: Agent identifier
+ session_name: Session name
+
+ Returns:
+ AgentSession if found and not expired, None otherwise
+ """
+ try:
+ # Check if agent has any named sessions
+ if agent_id not in self.named_sessions:
+ return None
+
+ # Check if specific session exists
+ session = self.named_sessions[agent_id].get(session_name)
+ if not session:
+ return None
+
+ # Check expiration
+ if datetime.now() - session.last_active > self.session_timeout:
+ logger.info(f"Named session '{session_name}' expired for agent '{agent_id}'")
+ del self.named_sessions[agent_id][session_name]
+ # Clean up empty agent entry
+ if not self.named_sessions[agent_id]:
+ del self.named_sessions[agent_id]
+ return None
+
+ # Update activity and return
+ session.update_activity()
+ return session
+
+ except Exception as e:
+ logger.error(f"Error getting named session '{session_name}' for agent '{agent_id}': {e}")
+ return None
+
+ def get_session_by_user_id(self, user_session_id: str) -> Optional[AgentSession]:
+ """
+ Get session by user-defined session ID (cross-context access)
+
+ This allows accessing sessions across different contexts using a
+ user-defined identifier.
+
+ Args:
+ user_session_id: User-defined session identifier
+
+ Returns:
+ AgentSession if found and not expired, None otherwise
+
+ Example:
+ # Access session from any context
+ session = session_manager.get_session_by_user_id("shared_browser_session")
+ """
+ try:
+ # Look up in user session mapping
+ if user_session_id not in self.user_session_mapping:
+ return None
+
+ agent_id, session_name = self.user_session_mapping[user_session_id]
+
+ # Get the actual session
+ session = self.get_named_session(agent_id, session_name)
+
+ # Clean up mapping if session expired
+ if not session:
+ del self.user_session_mapping[user_session_id]
+ if user_session_id in self.global_session_registry:
+ del self.global_session_registry[user_session_id]
+
+ return session
+
+ except Exception as e:
+ logger.error(f"Error getting session by user ID '{user_session_id}': {e}")
+ return None
+
+ def list_sessions_for_agent(self, agent_id: str) -> Dict[str, AgentSession]:
+ """
+ List all sessions for an agent
+
+ Args:
+ agent_id: Agent identifier
+
+ Returns:
+ Dictionary of session_name -> AgentSession
+ """
+ try:
+ if agent_id not in self.named_sessions:
+ return {}
+
+ # Filter out expired sessions
+ valid_sessions = {}
+ expired_sessions = []
+
+ for session_name, session in self.named_sessions[agent_id].items():
+ if datetime.now() - session.last_active <= self.session_timeout:
+ valid_sessions[session_name] = session
+ session.update_activity()
+ else:
+ expired_sessions.append(session_name)
+
+ # Clean up expired sessions
+ for session_name in expired_sessions:
+ del self.named_sessions[agent_id][session_name]
+ logger.info(f"Cleaned up expired named session '{session_name}' for agent '{agent_id}'")
+
+ # Clean up empty agent entry
+ if not self.named_sessions[agent_id]:
+ del self.named_sessions[agent_id]
+
+ return valid_sessions
+
+ except Exception as e:
+ logger.error(f"Error listing sessions for agent '{agent_id}': {e}")
+ return {}
+
+ def list_all_user_sessions(self) -> Dict[str, tuple[str, str]]:
+ """
+ List all user-defined sessions with their mappings
+
+ Returns:
+ Dictionary of user_session_id -> (agent_id, session_name)
+ """
+ # Clean up expired mappings first
+ expired_user_sessions = []
+
+ for user_session_id, (agent_id, session_name) in self.user_session_mapping.items():
+ session = self.get_named_session(agent_id, session_name)
+ if not session:
+ expired_user_sessions.append(user_session_id)
+
+ # Remove expired mappings
+ for user_session_id in expired_user_sessions:
+ del self.user_session_mapping[user_session_id]
+ if user_session_id in self.global_session_registry:
+ del self.global_session_registry[user_session_id]
+
+ return dict(self.user_session_mapping)
+
+ def register_user_session(self, user_session_id: str, agent_id: str, session_name: str) -> bool:
+ """
+ Register an existing named session with a user-defined ID
+
+ Args:
+ user_session_id: User-defined session identifier
+ agent_id: Agent identifier
+ session_name: Existing session name
+
+ Returns:
+ bool: True if registration successful, False otherwise
+ """
+ try:
+ # Verify the session exists
+ session = self.get_named_session(agent_id, session_name)
+ if not session:
+ logger.error(f"Cannot register user session '{user_session_id}': session '{session_name}' not found for agent '{agent_id}'")
+ return False
+
+ # Check for conflicts
+ if user_session_id in self.user_session_mapping:
+ existing_agent_id, existing_session_name = self.user_session_mapping[user_session_id]
+ logger.warning(f"User session ID '{user_session_id}' already maps to ({existing_agent_id}, {existing_session_name}), overwriting")
+
+ # Register mapping
+ self.user_session_mapping[user_session_id] = (agent_id, session_name)
+ self.global_session_registry[user_session_id] = (agent_id, session_name)
+
+ logger.info(f"Registered user session '{user_session_id}' -> ({agent_id}, {session_name})")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error registering user session '{user_session_id}': {e}")
+ return False
+
+ def unregister_user_session(self, user_session_id: str) -> bool:
+ """
+ Unregister a user-defined session ID
+
+ This removes the mapping but does not delete the underlying session.
+
+ Args:
+ user_session_id: User-defined session identifier
+
+ Returns:
+ bool: True if unregistration successful, False if not found
+ """
+ try:
+ if user_session_id not in self.user_session_mapping:
+ logger.warning(f"User session ID '{user_session_id}' not found for unregistration")
+ return False
+
+ # Remove mappings
+ del self.user_session_mapping[user_session_id]
+ if user_session_id in self.global_session_registry:
+ del self.global_session_registry[user_session_id]
+
+ logger.info(f"Unregistered user session '{user_session_id}'")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error unregistering user session '{user_session_id}': {e}")
+ return False
+
+ def cleanup_all_expired_sessions(self):
+ """
+ Enhanced cleanup that handles both original and named sessions
+ """
+ try:
+ # Clean up original sessions (backward compatibility)
+ self.cleanup_expired_sessions()
+
+ # Clean up named sessions
+ now = datetime.now()
+ agents_to_clean = []
+
+ for agent_id, sessions_dict in self.named_sessions.items():
+ expired_sessions = []
+
+ for session_name, session in sessions_dict.items():
+ if now - session.last_active > self.session_timeout:
+ expired_sessions.append(session_name)
+
+ # Remove expired sessions
+ for session_name in expired_sessions:
+ del sessions_dict[session_name]
+ logger.info(f"Cleaned up expired named session '{session_name}' for agent '{agent_id}'")
+
+ # Mark agent for cleanup if no sessions left
+ if not sessions_dict:
+ agents_to_clean.append(agent_id)
+
+ # Clean up empty agent entries
+ for agent_id in agents_to_clean:
+ del self.named_sessions[agent_id]
+
+ # Clean up orphaned user session mappings
+ orphaned_user_sessions = []
+ for user_session_id, (agent_id, session_name) in self.user_session_mapping.items():
+ if agent_id not in self.named_sessions or session_name not in self.named_sessions.get(agent_id, {}):
+ orphaned_user_sessions.append(user_session_id)
+
+ for user_session_id in orphaned_user_sessions:
+ del self.user_session_mapping[user_session_id]
+ if user_session_id in self.global_session_registry:
+ del self.global_session_registry[user_session_id]
+ logger.info(f"Cleaned up orphaned user session mapping '{user_session_id}'")
+
+ logger.info("Enhanced session cleanup completed")
+
+ except Exception as e:
+ logger.error(f"Error during enhanced session cleanup: {e}")
+
+ def get_session_statistics(self) -> Dict[str, Any]:
+ """
+ Get comprehensive session statistics
+
+ Returns:
+ Dictionary with session statistics
+ """
+ try:
+ # Count original sessions
+ original_sessions = len(self.sessions)
+
+ # Count named sessions
+ total_named_sessions = 0
+ agents_with_named_sessions = 0
+ for agent_sessions in self.named_sessions.values():
+ if agent_sessions:
+ agents_with_named_sessions += 1
+ total_named_sessions += len(agent_sessions)
+
+ # Count user mappings
+ user_mappings = len(self.user_session_mapping)
+
+ return {
+ "original_sessions": original_sessions,
+ "named_sessions": {
+ "total": total_named_sessions,
+ "agents_with_sessions": agents_with_named_sessions,
+ "average_per_agent": total_named_sessions / max(agents_with_named_sessions, 1)
+ },
+ "user_mappings": user_mappings,
+ "total_sessions": original_sessions + total_named_sessions,
+ "session_timeout_seconds": int(self.session_timeout.total_seconds())
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting session statistics: {e}")
+ return {"error": str(e)}
diff --git a/src/mcpstore/core/application/__init__.py b/src/mcpstore/core/application/__init__.py
new file mode 100644
index 00000000..db519dc2
--- /dev/null
+++ b/src/mcpstore/core/application/__init__.py
@@ -0,0 +1,14 @@
+"""
+应用层模块
+
+包含应用服务,协调领域服务完成用户请求:
+- ServiceApplicationService: 服务应用服务
+"""
+
+from .service_application_service import ServiceApplicationService, AddServiceResult
+
+__all__ = [
+ "ServiceApplicationService",
+ "AddServiceResult",
+]
+
diff --git a/src/mcpstore/core/application/service_application_service.py b/src/mcpstore/core/application/service_application_service.py
new file mode 100644
index 00000000..c1305c2b
--- /dev/null
+++ b/src/mcpstore/core/application/service_application_service.py
@@ -0,0 +1,465 @@
+"""
+服务应用服务 - 协调服务添加流程
+
+职责:
+1. 参数验证
+2. 生成 client_id
+3. 发布事件
+4. 等待状态收敛(可选)
+5. 返回结果给用户
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict, Any, Optional
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceAddRequested,
+ ServiceInitialized,
+ HealthCheckRequested,
+)
+from mcpstore.core.models.service import ServiceConnectionState
+from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AddServiceResult:
+ """服务添加结果"""
+ success: bool
+ service_name: str
+ client_id: str
+ final_state: Optional[str] = None
+ error_message: Optional[str] = None
+ duration_ms: float = 0.0
+
+
+class ServiceApplicationService:
+ """
+ 服务应用服务 - 用户操作的协调器
+
+ 职责:
+ 1. 参数验证
+ 2. 生成 client_id
+ 3. 发布事件
+ 4. 等待状态收敛(可选)
+ 5. 返回结果给用户
+ """
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ registry: 'CoreRegistry',
+ lifecycle_manager: 'LifecycleManager',
+ global_agent_store_id: str
+ ):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._lifecycle_manager = lifecycle_manager
+ self._global_agent_store_id = global_agent_store_id
+
+ logger.info("ServiceApplicationService initialized")
+
+ async def add_service(
+ self,
+ agent_id: str,
+ service_name: str,
+ service_config: Dict[str, Any],
+ wait_timeout: float = 0.0,
+ source: str = "user",
+ global_name: Optional[str] = None,
+ client_id: Optional[str] = None,
+ origin_agent_id: Optional[str] = None,
+ origin_local_name: Optional[str] = None,
+ ) -> AddServiceResult:
+ """
+ 添加服务(用户API)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ service_config: 服务配置
+ wait_timeout: 等待超时(0表示不等待)
+ source: 调用来源
+
+ Returns:
+ AddServiceResult: 添加结果
+ """
+ start_time = asyncio.get_event_loop().time()
+
+ try:
+ # 1. 参数验证
+ self._validate_params(service_name, service_config)
+
+ # 2. 生成 client_id
+ cid = client_id or await self._generate_client_id(agent_id, service_name, service_config)
+
+ logger.info(
+ f"[ADD_SERVICE] Starting: service={service_name}, "
+ f"agent={agent_id}, client_id={cid}"
+ )
+
+ # 3. 发布服务添加请求事件
+ event = ServiceAddRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ client_id=cid,
+ global_name=global_name or "",
+ origin_agent_id=origin_agent_id,
+ origin_local_name=origin_local_name,
+ source=source,
+ wait_timeout=wait_timeout
+ )
+
+ await self._event_bus.publish(event, wait=False)
+
+ # 4. 等待状态收敛(可选)
+ final_state = None
+ if wait_timeout > 0:
+ final_state = await self._wait_for_state_convergence(
+ agent_id, service_name, wait_timeout
+ )
+
+ duration_ms = (asyncio.get_event_loop().time() - start_time) * 1000
+
+ logger.info(
+ f"[ADD_SERVICE] Completed: service={service_name}, "
+ f"state={final_state}, duration={duration_ms:.2f}ms"
+ )
+
+ return AddServiceResult(
+ success=True,
+ service_name=service_name,
+ client_id=client_id,
+ final_state=final_state,
+ duration_ms=duration_ms
+ )
+
+ except Exception as e:
+ duration_ms = (asyncio.get_event_loop().time() - start_time) * 1000
+ logger.error(f"[ADD_SERVICE] Failed: service={service_name}, error={e}", exc_info=True)
+
+ return AddServiceResult(
+ success=False,
+ service_name=service_name,
+ client_id="",
+ error_message=str(e),
+ duration_ms=duration_ms
+ )
+
+ async def restart_service(
+ self,
+ service_name: str,
+ agent_id: Optional[str] = None,
+ wait_timeout: float = 0.0,
+ ) -> bool:
+ """重启服务(应用层 API)
+
+ - 通过 LifecycleManager 将状态迁移到 INITIALIZING;
+ - 重置基础元数据计数器;
+ - 发布 ServiceInitialized + HealthCheckRequested 事件;
+ - 可选:等待状态从 INITIALIZING 收敛到其他状态。
+ """
+ start_time = asyncio.get_event_loop().time()
+ agent_key = agent_id or self._global_agent_store_id
+
+ try:
+ # 1. 校验服务是否存在(使用异步 API)
+ if not await self._registry.has_service_async(agent_key, service_name):
+ logger.warning(
+ f"[RESTART_SERVICE_APP] Service '{service_name}' not found for agent {agent_key}"
+ )
+ return False
+
+ # 2. 读取并校验元数据 - 从 pykv 异步获取
+ metadata = await self._registry._service_state_service.get_service_metadata_async(agent_key, service_name)
+ if not metadata:
+ logger.error(
+ f"[RESTART_SERVICE_APP] No metadata found for service '{service_name}' (agent={agent_key})"
+ )
+ return False
+
+ # 3. 通过 LifecycleManager 统一入口迁移到 INITIALIZING
+ await self._lifecycle_manager._transition_state(
+ agent_id=agent_key,
+ service_name=service_name,
+ new_state=ServiceConnectionState.INITIALIZING,
+ reason="restart_service",
+ source="ServiceApplicationService",
+ )
+
+ # 4. 重置元数据计数器
+ metadata.consecutive_failures = 0
+ metadata.consecutive_successes = 0
+ metadata.reconnect_attempts = 0
+ metadata.error_message = None
+ metadata.state_entered_time = datetime.now()
+ metadata.next_retry_time = None
+ self._registry.set_service_metadata(agent_key, service_name, metadata)
+
+ # 5. 发布初始化完成 + 一次性健康检查请求事件
+ initialized_event = ServiceInitialized(
+ agent_id=agent_key,
+ service_name=service_name,
+ initial_state="initializing",
+ )
+ await self._event_bus.publish(initialized_event, wait=True)
+
+ health_check_event = HealthCheckRequested(
+ agent_id=agent_key,
+ service_name=service_name,
+ )
+ await self._event_bus.publish(health_check_event, wait=True)
+
+ # 6. 可选:等待状态收敛
+ if wait_timeout > 0:
+ final_state = await self._wait_for_state_convergence(
+ agent_key, service_name, wait_timeout
+ )
+ logger.info(
+ f"[RESTART_SERVICE_APP] Completed restart for '{service_name}' "
+ f"state={final_state} agent={agent_key}"
+ )
+ else:
+ logger.info(
+ f"[RESTART_SERVICE_APP] Restart triggered for '{service_name}' "
+ f"(no wait, agent={agent_key})"
+ )
+
+ duration_ms = (asyncio.get_event_loop().time() - start_time) * 1000
+ try:
+ logger.debug(
+ f"[RESTART_SERVICE_APP] duration={duration_ms:.2f}ms service='{service_name}' agent={agent_key}"
+ )
+ except Exception:
+ pass
+
+ return True
+
+ except Exception as e:
+ logger.error(
+ f"[RESTART_SERVICE_APP] Failed to restart service '{service_name}' (agent={agent_key}): {e}",
+ exc_info=True,
+ )
+ return False
+
+ async def reset_service(
+ self,
+ agent_id: str,
+ service_name: str,
+ wait_timeout: float = 0.0,
+ ) -> bool:
+ start_time = asyncio.get_event_loop().time()
+
+ try:
+ # 使用异步 API 检查服务是否存在
+ if not await self._registry.has_service_async(agent_id, service_name):
+ logger.warning(
+ f"[RESET_SERVICE_APP] Service '{service_name}' not found for agent {agent_id}"
+ )
+ return False
+
+ service_config = await self._registry.get_service_config_from_cache_async(agent_id, service_name)
+ if not service_config:
+ logger.error(
+ f"[RESET_SERVICE_APP] No service config found for '{service_name}' (agent={agent_id})"
+ )
+ return False
+
+ success = await self._lifecycle_manager.initialize_service(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ )
+ if not success:
+ logger.error(
+ f"[RESET_SERVICE_APP] initialize_service returned False for '{service_name}' (agent={agent_id})"
+ )
+ return False
+
+ if wait_timeout > 0:
+ final_state = await self._wait_for_state_convergence(
+ agent_id, service_name, wait_timeout
+ )
+ logger.info(
+ f"[RESET_SERVICE_APP] Completed reset for '{service_name}' "
+ f"state={final_state} agent={agent_id}"
+ )
+ else:
+ logger.info(
+ f"[RESET_SERVICE_APP] Reset triggered for '{service_name}' "
+ f"(no wait, agent={agent_id})"
+ )
+
+ duration_ms = (asyncio.get_event_loop().time() - start_time) * 1000
+ try:
+ logger.debug(
+ f"[RESET_SERVICE_APP] duration={duration_ms:.2f}ms service='{service_name}' agent={agent_id}"
+ )
+ except Exception:
+ pass
+
+ return True
+
+ except Exception as e:
+ logger.error(
+ f"[RESET_SERVICE_APP] Failed to reset service '{service_name}' (agent={agent_id}): {e}",
+ exc_info=True,
+ )
+ return False
+
+ async def get_service_status_async(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """读取单个服务的状态信息(只读,从 pykv 异步获取)"""
+ try:
+ state = await self._registry._service_state_service.get_service_state_async(agent_id, service_name)
+ metadata = await self._registry._service_state_service.get_service_metadata_async(agent_id, service_name)
+ client_id = await self._registry.get_service_client_id_async(agent_id, service_name)
+
+ status_response: Dict[str, Any] = {
+ "service_name": service_name,
+ "agent_id": agent_id,
+ "client_id": client_id,
+ }
+
+ # 状态与健康度
+ if state:
+ status_response["status"] = getattr(state, "value", str(state))
+ status_response["healthy"] = state in [
+ ServiceConnectionState.HEALTHY,
+ ServiceConnectionState.WARNING,
+ ]
+ else:
+ status_response["status"] = "unknown"
+ status_response["healthy"] = False
+
+ # 元数据
+ if metadata:
+ status_response["last_check"] = (
+ metadata.last_health_check.timestamp()
+ if getattr(metadata, "last_health_check", None)
+ else None
+ )
+ status_response["response_time"] = getattr(
+ metadata, "last_response_time", None
+ )
+ status_response["error"] = getattr(metadata, "error_message", None)
+ status_response["consecutive_failures"] = getattr(
+ metadata, "consecutive_failures", 0
+ )
+ status_response["state_entered_time"] = (
+ metadata.state_entered_time.timestamp()
+ if getattr(metadata, "state_entered_time", None)
+ else None
+ )
+ else:
+ status_response.setdefault("last_check", None)
+ status_response.setdefault("response_time", None)
+ status_response.setdefault("error", None)
+ status_response.setdefault("consecutive_failures", 0)
+ status_response.setdefault("state_entered_time", None)
+
+ logger.info(
+ f"[GET_STATUS_APP] service='{service_name}' agent='{agent_id}' "
+ f"status='{status_response.get('status')}' healthy={status_response.get('healthy')}"
+ )
+ return status_response
+
+ except Exception as e:
+ logger.error(
+ f"[GET_STATUS_APP] Failed to get status for service '{service_name}' (agent={agent_id}): {e}",
+ exc_info=True,
+ )
+ return {
+ "service_name": service_name,
+ "agent_id": agent_id,
+ "client_id": None,
+ "status": "error",
+ "healthy": False,
+ "last_check": None,
+ "response_time": None,
+ "error": str(e),
+ "consecutive_failures": 0,
+ "state_entered_time": None,
+ }
+
+ def _validate_params(self, service_name: str, service_config: Dict[str, Any]):
+ """验证参数"""
+ if not service_name:
+ raise ValueError("service_name cannot be empty")
+
+ if not service_config:
+ raise ValueError("service_config cannot be empty")
+
+ # 验证必要字段
+ if "command" not in service_config and "url" not in service_config:
+ raise ValueError("service_config must contain 'command' or 'url'")
+
+ async def _generate_client_id(
+ self,
+ agent_id: str,
+ service_name: str,
+ service_config: Dict[str, Any]
+ ) -> str:
+ """生成 client_id(优先异步获取已有映射,避免事件循环冲突)"""
+ # 优先使用异步 API,避免在运行事件循环中调用同步桥接
+ existing_client_id = None
+ try:
+ existing_client_id = await self._registry.get_service_client_id_async(agent_id, service_name)
+ except Exception as e:
+ logger.warning(f"Failed to get existing client_id asynchronously: {e}")
+
+ if existing_client_id:
+ logger.debug(f"Using existing client_id: {existing_client_id}")
+ return existing_client_id
+
+ # 生成新的
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ global_agent_store_id=self._global_agent_store_id
+ )
+
+ logger.debug(f"Generated new client_id: {client_id}")
+ return client_id
+
+ async def _wait_for_state_convergence(
+ self,
+ agent_id: str,
+ service_name: str,
+ timeout: float
+ ) -> Optional[str]:
+ """
+ 等待服务状态收敛
+
+ 状态收敛定义: 状态不再是 INITIALIZING
+ """
+ logger.debug(f"[WAIT_STATE] Waiting for {service_name} (timeout={timeout}s)")
+
+ start_time = asyncio.get_event_loop().time()
+ check_interval = 0.1 # 100ms
+
+ while True:
+ # 检查超时
+ elapsed = asyncio.get_event_loop().time() - start_time
+ if elapsed >= timeout:
+ logger.warning(f"[WAIT_STATE] Timeout for {service_name}")
+ break
+
+ # 检查状态
+ state = self._registry._service_state_service.get_service_state(agent_id, service_name)
+ if state and state != ServiceConnectionState.INITIALIZING:
+ logger.debug(f"[WAIT_STATE] Converged: {service_name} -> {state.value}")
+ return state.value
+
+ # 等待一段时间再检查
+ await asyncio.sleep(check_interval)
+
+ # 超时,返回当前状态
+ state = self._registry._service_state_service.get_service_state(agent_id, service_name)
+ return state.value if state else "unknown"
diff --git a/src/mcpstore/core/architecture/__init__.py b/src/mcpstore/core/architecture/__init__.py
new file mode 100644
index 00000000..86db95de
--- /dev/null
+++ b/src/mcpstore/core/architecture/__init__.py
@@ -0,0 +1,30 @@
+"""
+Architecture Module - Functional Core, Imperative Shell
+
+新架构模块,提供:
+1. ServiceManagementCore - 纯同步业务逻辑核心
+2. ServiceManagementAsyncShell - 异步外壳
+3. ServiceManagementSyncShell - 同步外壳
+4. ServiceManagementFactory - 工厂类
+5. ShowConfigLogicCore - show_config 纯逻辑核心
+6. ShowConfigAsyncShell - show_config 异步外壳
+
+这个模块解决了原有的同步/异步混用导致的死锁问题。
+"""
+
+from .service_management_core import ServiceManagementCore, ServiceOperationPlan, WaitOperationPlan, ServiceOperation
+from .service_management_shells import ServiceManagementAsyncShell, ServiceManagementSyncShell, ServiceManagementFactory
+from .show_config_core import ShowConfigLogicCore
+from .show_config_shell import ShowConfigAsyncShell
+
+__all__ = [
+ "ServiceManagementCore",
+ "ServiceManagementAsyncShell",
+ "ServiceManagementSyncShell",
+ "ServiceManagementFactory",
+ "ServiceOperationPlan",
+ "WaitOperationPlan",
+ "ServiceOperation",
+ "ShowConfigLogicCore",
+ "ShowConfigAsyncShell",
+]
\ No newline at end of file
diff --git a/src/mcpstore/core/architecture/service_management_core.py b/src/mcpstore/core/architecture/service_management_core.py
new file mode 100644
index 00000000..b705ccf6
--- /dev/null
+++ b/src/mcpstore/core/architecture/service_management_core.py
@@ -0,0 +1,313 @@
+"""
+Service Management Core - 纯同步业务逻辑核心
+
+根据Functional Core, Imperative Shell架构原则:
+- 纯同步执行
+- 不包含任何IO操作
+- 不调用任何异步方法
+- 只返回操作计划,不执行实际操作
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import Dict, Any, List
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ServiceOperation:
+ """单个服务操作的数据结构"""
+ type: str # "put_entity", "put_relation", "update_state"
+ collection: str # pykv collection名称
+ key: str # pykv key
+ data: Dict[str, Any] # 操作数据
+
+
+@dataclass
+class ServiceOperationPlan:
+ """服务操作计划"""
+ operations: List[ServiceOperation]
+ service_names: List[str] # 涉及的服务名称列表
+
+ def __post_init__(self):
+ """后处理:提取服务名称"""
+ if not self.service_names:
+ self.service_names = [
+ op.data.get("service_original_name") or op.data.get("service_name")
+ for op in self.operations
+ if op.data.get("service_original_name") or op.data.get("service_name")
+ ]
+
+
+@dataclass
+class WaitOperationPlan:
+ """等待服务操作计划"""
+ service_name: str
+ global_name: str
+ target_status: str
+ timeout: float
+ check_interval: float
+
+
+class ServiceManagementCore:
+ """
+ 纯同步的服务管理核心逻辑
+
+ 严格遵循Functional Core原则:
+ - Pure synchronous execution
+ - No IO operations
+ - No async method calls
+ - No await/asyncio.run()
+ - Only return operation plans, do not execute actual operations
+ """
+
+ def __init__(self, agent_id: str = "global_agent_store"):
+ """初始化核心逻辑"""
+ logger.debug("[SERVICE_CORE] [INIT] Initializing ServiceManagementCore")
+ self.agent_id = agent_id
+
+ def add_service(self, config: Dict[str, Any]) -> ServiceOperationPlan:
+ """
+ 纯同步:解析服务配置,生成操作计划
+
+ Args:
+ config: 服务配置,支持多种格式:
+ - {"mcpServers": {"service1": {...}, "service2": {...}}}
+ - {"name": "service1", "url": "...", ...}
+ - 字符串URL
+
+ Returns:
+ ServiceOperationPlan: 包含所有需要执行的操作计划
+ """
+ logger.debug(f"[SERVICE_CORE] [PARSE] Starting to parse service configuration: {type(config).__name__}")
+
+ # 1. 解析配置,标准化为服务字典
+ service_configs = self._parse_service_config(config)
+
+ if not service_configs:
+ raise ValueError("Invalid service configuration, unable to parse any services")
+
+ # 2. 生成操作计划
+ operations = []
+ service_names = []
+
+ for service_name, service_config in service_configs.items():
+ logger.debug(f"[SERVICE_CORE] [PROCESS] Processing service: {service_name}")
+
+ # 使用当前上下文的 agent_id(store=global_agent_store,agent上下文则为具体ID)
+ agent_id = self.agent_id or "global_agent_store"
+ # 使用NamingService生成全局名称,确保与缓存层一致
+ from ..cache.naming_service import NamingService
+ naming = NamingService()
+ global_name = naming.generate_service_global_name(service_name, agent_id)
+
+ # 构建服务实体数据
+ service_entity_data = self._build_service_entity_data(
+ agent_id=agent_id,
+ original_name=service_name,
+ global_name=global_name,
+ config=service_config
+ )
+
+ # 操作1: 创建服务实体
+ operations.append(ServiceOperation(
+ type="put_entity",
+ collection="default:entity:services",
+ key=global_name,
+ data={
+ "key": global_name, # 缓存层需要的 key
+ "value": service_entity_data, # 缓存层需要的 value
+ # 保留原有数据以备其他用途
+ "agent_id": agent_id,
+ "original_name": service_name,
+ "global_name": global_name,
+ "config": service_config
+ }
+ ))
+
+ # 操作2: 创建Agent-Service关系
+ client_id = f"client_{agent_id}_{service_name}"
+ relation_data = {
+ "agent_id": agent_id,
+ "service_name": service_name,
+ "client_id": client_id,
+ "global_name": global_name
+ }
+ operations.append(ServiceOperation(
+ type="put_relation",
+ collection="default:relations:agent_services",
+ key=f"{agent_id}:{service_name}",
+ data={
+ "key": f"{agent_id}:{service_name}", # 缓存层需要的 key
+ "value": relation_data, # 缓存层需要的 value
+ # 保留原有数据以备其他用途
+ "agent_id": agent_id,
+ "service_original_name": service_name,
+ "service_global_name": global_name,
+ "client_id": client_id,
+ "relation_data": relation_data
+ }
+ ))
+
+ # 操作3: 初始化服务状态
+ import time
+ state_data = {
+ "service_global_name": global_name, # 必需字段
+ "health_status": "initializing", # 初始状态:正在初始化
+ "last_health_check": int(time.time()), # 必需字段
+ "connection_attempts": 0, # 必需字段
+ "max_connection_attempts": 3, # 必需字段
+ "current_error": None, # 可选字段
+ "tools": [] # 工具状态列表
+ }
+ operations.append(ServiceOperation(
+ type="update_state",
+ collection="default:state:service_status",
+ key=global_name,
+ data={
+ "key": global_name, # 缓存层需要的 key
+ "value": state_data, # 缓存层需要的 value
+ # 保留原有数据以备其他用途
+ "global_name": global_name,
+ "health_status": "initializing",
+ "tools_status": [], # 兼容性保留
+ "error_message": None,
+ "last_heartbeat": None
+ }
+ ))
+
+ # 操作4: 初始化 service_metadata
+ metadata_data = {
+ "service_global_name": global_name,
+ "agent_id": agent_id,
+ "created_time": int(time.time()),
+ "state_entered_time": int(time.time()),
+ "reconnect_attempts": 0,
+ "last_ping_time": None,
+ }
+ operations.append(ServiceOperation(
+ type="put_metadata",
+ collection="default:state:service_metadata",
+ key=global_name,
+ data={
+ "key": global_name,
+ "value": metadata_data,
+ }
+ ))
+
+ service_names.append(service_name)
+
+ logger.debug(f"[SERVICE_CORE] [PLAN] Generated operation plan: {len(operations)} operations, {len(service_names)} services")
+
+ return ServiceOperationPlan(
+ operations=operations,
+ service_names=service_names
+ )
+
+ def wait_service_plan(self, service_name: str, timeout: float = 40.0) -> WaitOperationPlan:
+ """
+ 纯同步:生成等待服务的操作计划
+
+ Args:
+ service_name: 服务名称
+ timeout: 超时时间
+
+ Returns:
+ WaitOperationPlan: 等待操作计划
+ """
+ logger.debug(f"[SERVICE_CORE] [PLAN] Generated wait plan: {service_name}, timeout={timeout}")
+
+ agent_id = self.agent_id or "global_agent_store"
+ # 使用NamingService生成全局名称,确保与缓存层一致
+ from ..cache.naming_service import NamingService
+ naming = NamingService()
+ global_name = naming.generate_service_global_name(service_name, agent_id)
+
+ return WaitOperationPlan(
+ service_name=service_name,
+ global_name=global_name,
+ target_status="healthy",
+ timeout=timeout,
+ check_interval=0.5 # 每0.5秒检查一次
+ )
+
+ # ===================== 私有辅助方法 =====================
+
+ def _parse_service_config(self, config: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+ """
+ 纯同步:解析各种格式的服务配置
+
+ 支持格式:
+ 1. {"mcpServers": {"service1": {...}, "service2": {...}}}
+ 2. {"name": "service1", "url": "...", ...}
+ 3. {"service_name": {"url": "...", ...}, ...}
+ """
+ if not isinstance(config, dict):
+ raise ValueError(f"Configuration must be a dictionary type, actual type: {type(config).__name__}")
+
+ # 格式1: mcpServers格式
+ if "mcpServers" in config:
+ mcp_servers = config["mcpServers"]
+ if not isinstance(mcp_servers, dict):
+ raise ValueError("mcpServers must be a dictionary type")
+ return mcp_servers
+
+ # 格式2: 单个服务配置(有name字段)
+ if "name" in config:
+ service_name = config["name"]
+ if not isinstance(service_name, str):
+ raise ValueError("Service name must be a string")
+ return {service_name: config}
+
+ # 格式3: 直接是服务字典
+ # 假设所有值都是服务配置
+ service_configs = {}
+ for key, value in config.items():
+ if isinstance(value, dict) and ("url" in value or "command" in value):
+ service_configs[key] = value
+
+ if service_configs:
+ return service_configs
+
+ raise ValueError("Unrecognized service configuration format")
+
+ def _generate_global_name(self, service_name: str, agent_id: str) -> str:
+ """纯同步:生成全局服务名称"""
+ return f"{agent_id}::{service_name}"
+
+ def _build_service_entity_data(self, agent_id: str, original_name: str, global_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ """纯同步:构建服务实体数据"""
+ import time
+
+ return {
+ "service_global_name": global_name,
+ "service_original_name": original_name,
+ "source_agent": agent_id,
+ "config": config,
+ "added_time": int(time.time()),
+ "transport_type": self._infer_transport_type(config),
+ "tool_count": 0 # 初始为0,后续可能更新
+ }
+
+ def _infer_transport_type(self, config: Dict[str, Any]) -> str:
+ """纯同步:推断传输类型"""
+ # 优先检查transport字段
+ transport = config.get("transport")
+ if transport:
+ return str(transport)
+
+ # 检查URL
+ if config.get("url"):
+ return "streamable_http"
+
+ # 检查命令
+ cmd = (config.get("command") or "").lower()
+ args = " ".join(config.get("args", [])).lower()
+
+ if "npx" in cmd or "node" in cmd or "npm" in cmd:
+ return "stdio"
+ if "python" in cmd or "pip" in cmd or ".py" in args:
+ return "stdio"
+
+ return "streamable_http" # 默认
diff --git a/src/mcpstore/core/architecture/service_management_shells.py b/src/mcpstore/core/architecture/service_management_shells.py
new file mode 100644
index 00000000..469bbc1f
--- /dev/null
+++ b/src/mcpstore/core/architecture/service_management_shells.py
@@ -0,0 +1,538 @@
+"""
+Service Management Shells - 双路外壳实现
+
+异步外壳和同步外壳的完整实现,严格遵循Functional Core, Imperative Shell架构原则。
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any
+
+from .service_management_core import ServiceManagementCore
+from ..bridge import get_async_bridge
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceManagementAsyncShell:
+ """
+ 异步外壳:执行所有IO操作
+
+ 特点:
+ - 只在入口处调用一次核心逻辑
+ - 之后纯异步执行,不再有任何同步/异步混用
+ - 直接调用pykv异步方法,避免_sync_to_kv
+ """
+
+ def __init__(self, core: ServiceManagementCore, registry, orchestrator):
+ """
+ 初始化异步外壳
+
+ Args:
+ core: 纯同步核心逻辑实例
+ registry: ServiceRegistry实例
+ orchestrator: MCPOrchestrator实例
+ """
+ self.core = core
+ self.registry = registry
+ self.orchestrator = orchestrator
+ logger.debug("[ASYNC_SHELL] [INIT] Initializing ServiceManagementAsyncShell")
+
+ async def add_service_async(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 异步外壳:执行服务添加
+
+ 严格按照新架构原则:
+ 1. 调用纯同步核心(无IO,无锁,无异步)
+ 2. 纯异步执行所有pykv操作
+ 3. 避免任何_sync_to_kv调用
+
+ 修复:使用正确的缓存层管理器方法
+ """
+ logger.debug("[ASYNC_SHELL] [START] Starting to add service")
+
+ try:
+ # 1. 调用纯同步核心(这是唯一可能调用同步逻辑的地方)
+ operation_plan = self.core.add_service(config)
+ logger.debug(f"[ASYNC_SHELL] [PLAN] Got operation plan: {len(operation_plan.operations)} operations")
+
+ # 2. 获取正确的缓存层管理器
+ # 优先使用 cache/ 目录下的管理器(直接操作 pykv)
+ # 这些管理器是数据的唯一真相源
+ service_manager = getattr(self.registry, '_cache_service_manager', None)
+ relation_manager = getattr(self.registry, '_relation_manager', None)
+ state_manager = getattr(self.registry, '_cache_state_manager', None)
+
+ # 如果缓存层管理器不存在,抛出错误(不做降级处理)
+ if service_manager is None:
+ raise RuntimeError(
+ "Cache layer ServiceEntityManager not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _cache_service_manager attribute."
+ )
+
+ # 3. 纯异步执行所有操作
+ results = []
+ successful_operations = []
+
+ for i, operation in enumerate(operation_plan.operations):
+ logger.debug(f"[ASYNC_SHELL] [EXEC] Executing operation {i+1}/{len(operation_plan.operations)}: {operation.type}")
+
+ try:
+ if operation.type == "put_entity":
+ # 使用 cache/ServiceEntityManager 创建服务实体
+ await service_manager.create_service(
+ agent_id=operation.data.get("agent_id", "global_agent_store"),
+ original_name=operation.data.get("original_name", operation.data["key"]),
+ config=operation.data.get("config", operation.data.get("value", {}))
+ )
+ logger.debug(f"[ASYNC_SHELL] [SUCCESS] create_service successful, key={operation.data['key']}")
+ successful_operations.append(operation)
+ results.append({"operation": operation.key, "status": "success"})
+
+ elif operation.type == "put_relation":
+ # 使用 cache/RelationshipManager 创建关系
+ if relation_manager is None:
+ raise RuntimeError(
+ "Cache layer RelationshipManager not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _relation_manager attribute."
+ )
+ await relation_manager.add_agent_service(
+ agent_id=operation.data.get("agent_id", "global_agent_store"),
+ service_original_name=operation.data.get("service_original_name", ""),
+ service_global_name=operation.data.get("service_global_name", operation.data["key"]),
+ client_id=operation.data.get("client_id", f"client_{operation.data['key']}")
+ )
+ logger.debug(f"[ASYNC_SHELL] [SUCCESS] add_agent_service successful, key={operation.data['key']}")
+ successful_operations.append(operation)
+ results.append({"operation": operation.key, "status": "success"})
+
+ elif operation.type == "update_state":
+ # 使用 cache/StateManager 更新状态
+ if state_manager is None:
+ raise RuntimeError(
+ "Cache layer StateManager not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _cache_state_manager attribute."
+ )
+ await state_manager.update_service_status(
+ service_global_name=operation.data["key"],
+ health_status=operation.data.get("health_status", "initializing"),
+ tools_status=operation.data.get("tools_status", [])
+ )
+ logger.debug(f"[ASYNC_SHELL] [SUCCESS] update_state successful, key={operation.data['key']}")
+ successful_operations.append(operation)
+ results.append({"operation": operation.key, "status": "success"})
+
+ elif operation.type == "put_metadata":
+ cache_layer = getattr(self.registry, "_cache_layer_manager", None)
+ if cache_layer is None:
+ raise RuntimeError("Cache layer CacheLayerManager is not initialized.")
+ await cache_layer.put_state(
+ "service_metadata",
+ operation.data["key"],
+ operation.data.get("value", {})
+ )
+ logger.debug(f"[ASYNC_SHELL] [SUCCESS] put_metadata successful, key={operation.data['key']}")
+ successful_operations.append(operation)
+ results.append({"operation": operation.key, "status": "success"})
+
+ else:
+ raise ValueError(f"Unknown operation type: {operation.type}")
+
+ except Exception as e:
+ logger.error(f"[ASYNC_SHELL] [ERROR] Operation failed {operation.key}: {e}")
+ results.append({"operation": operation.key, "status": "failed", "error": str(e)})
+ # 按要求抛出错误,不做静默处理
+ raise
+
+ # 服务的实际连接交由事件驱动流程(ServiceAddRequested → ServiceCached → ServiceInitialized → ConnectionManager)
+ logger.info(f"[ASYNC_SHELL] [COMPLETE] Service addition completed: {len(operation_plan.service_names)} services, {len([r for r in results if r['status'] == 'success'])} successful")
+
+ # 4. 发布 ServiceAddRequested 事件,触发事件驱动的连接流程
+ # 这是关键修复:确保连接流程被触发
+ try:
+ # 获取 event_bus(优先从 orchestrator.container 获取,否则从 orchestrator 获取)
+ event_bus = None
+ if self.orchestrator:
+ event_bus = getattr(getattr(self.orchestrator, 'container', None), '_event_bus', None)
+ if event_bus is None:
+ event_bus = getattr(self.orchestrator, 'event_bus', None)
+
+ if event_bus is None:
+ logger.warning("[ASYNC_SHELL] [WARN] EventBus unavailable, cannot publish ServiceAddRequested event. Connection flow may not start.")
+ else:
+ # 为每个成功添加的服务发布 ServiceAddRequested 事件
+ from mcpstore.core.events.service_events import ServiceAddRequested
+
+ for service_name in operation_plan.service_names:
+ # 从 operations 中提取服务信息
+ service_info = None
+ client_id = None
+ agent_id = None
+ service_config = None
+
+ # 查找 put_entity 操作获取服务配置
+ for op in operation_plan.operations:
+ if op.type == "put_entity" and op.data.get("original_name") == service_name:
+ agent_id = op.data.get("agent_id", "global_agent_store")
+ service_config = op.data.get("config", {})
+ break
+
+ # 查找 put_relation 操作获取 client_id
+ for op in operation_plan.operations:
+ if op.type == "put_relation" and op.data.get("service_original_name") == service_name:
+ client_id = op.data.get("client_id")
+ if agent_id is None:
+ agent_id = op.data.get("agent_id", "global_agent_store")
+ break
+
+ # 如果找不到 service_config,尝试从 config 参数中获取
+ if not service_config:
+ # 从原始 config 中提取
+ if isinstance(config, dict):
+ if "mcpServers" in config:
+ service_config = config["mcpServers"].get(service_name, {})
+ elif "name" in config and config.get("name") == service_name:
+ service_config = {k: v for k, v in config.items() if k != "name"}
+ else:
+ service_config = config
+
+ # 确保有 service_config 才能生成 client_id
+ if not service_config:
+ raise RuntimeError(f"Unable to get service configuration, cannot generate client_id: {service_name}")
+
+ # 如果找不到 client_id,使用 ClientIDGenerator 生成一个
+ if client_id is None:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ global_agent_store_id = getattr(getattr(self.orchestrator, 'client_manager', None), 'global_agent_store_id', 'global_agent_store')
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id or "global_agent_store",
+ service_name=service_name,
+ service_config=service_config,
+ global_agent_store_id=global_agent_store_id
+ )
+
+ if service_config:
+ # 发布 ServiceAddRequested 事件
+ add_event = ServiceAddRequested(
+ agent_id=agent_id or "global_agent_store",
+ service_name=service_name,
+ service_config=service_config,
+ client_id=client_id,
+ source="service_management_shell",
+ wait_timeout=0.0
+ )
+ await event_bus.publish(add_event, wait=True)
+
+ logger.info(f"[ASYNC_SHELL] [EVENT] ServiceAddRequested event published: {service_name} (agent={agent_id or 'global_agent_store'})")
+ else:
+ logger.warning(f"[ASYNC_SHELL] [WARN] Cannot publish ServiceAddRequested event for service {service_name}: service configuration not found")
+ except Exception as event_error:
+ logger.error(f"[ASYNC_SHELL] [ERROR] Failed to publish ServiceAddRequested event: {event_error}", exc_info=True)
+ # 不抛出异常,允许服务添加成功返回,但记录错误
+
+ return {
+ "success": True,
+ "added_services": operation_plan.service_names,
+ "operations": results,
+ "total_operations": len(operation_plan.operations),
+ "successful_operations": len([r for r in results if r['status'] == 'success'])
+ }
+
+ except Exception as e:
+ logger.error(f"[ASYNC_SHELL] [ERROR] add_service_async failed: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "added_services": [],
+ "operations": [],
+ "total_operations": 0,
+ "successful_operations": 0
+ }
+
+ async def wait_service_async(self, service_name: str, timeout: float | None = None) -> bool:
+ """
+ 异步外壳:等待服务就绪
+
+ 严格按照新架构原则:
+ 1. 调用纯同步核心生成等待计划
+ 2. 纯异步执行状态检查循环
+ 3. 直接从pykv读取状态,使用缓存层管理器
+ """
+ logger.debug(f"[ASYNC_SHELL] [WAIT] Starting to wait for service: {service_name}, timeout={timeout}")
+
+ try:
+ # 1. 如未显式传入,按传输类型选择默认超时
+ effective_timeout = timeout
+
+ # 先生成计划以获取全局名,再读取配置推断传输
+ draft_plan = self.core.wait_service_plan(service_name, timeout or 0)
+ agent_id = getattr(self.core, "agent_id", "global_agent_store") or "global_agent_store"
+ if effective_timeout is None:
+ try:
+ info = await self.registry.get_complete_service_info_async(agent_id, draft_plan.global_name)
+ cfg = info.get("config", {}) if isinstance(info, dict) else {}
+ transport = str(cfg.get("transport", "")).lower()
+ if not transport and cfg.get("url"):
+ transport = "http"
+ if not transport and (cfg.get("command") or cfg.get("args")):
+ transport = "stdio"
+ # 从生命周期配置获取对应超时
+ lm = getattr(self.orchestrator, "lifecycle_manager", None)
+ lc = getattr(lm, "_config", None)
+ def _get(name, default):
+ return getattr(lc, name, default) if lc else default
+ if "sse" in transport:
+ effective_timeout = _get("ping_timeout_sse", 20.0)
+ elif "stdio" in transport:
+ effective_timeout = _get("ping_timeout_stdio", 40.0)
+ else:
+ effective_timeout = _get("ping_timeout_http", 20.0)
+ except Exception:
+ effective_timeout = timeout or 20.0
+
+ # 2. 调用纯同步核心(使用计算后的超时)
+ wait_plan = self.core.wait_service_plan(service_name, effective_timeout)
+ logger.debug(f"[ASYNC_SHELL] [PLAN] Wait plan: {wait_plan}")
+
+ # 2. 获取缓存层状态管理器
+ # cache/state_manager.py 的方法签名是 get_service_status(service_global_name)
+ state_manager = getattr(self.registry, '_cache_state_manager', None)
+ if state_manager is None:
+ raise RuntimeError(
+ "Cache layer StateManager not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _cache_state_manager attribute."
+ )
+
+ # 3. 纯异步等待检查
+ start_time = asyncio.get_event_loop().time()
+
+ while True:
+ try:
+ # 使用 cache/state_manager.py: get_service_status(service_global_name)
+ # 注意:方法签名是 (service_global_name),不是 (agent_id, service_name)
+ state_data = await state_manager.get_service_status(wait_plan.global_name)
+
+ # 处理 ServiceStatus 对象
+ if state_data:
+ if hasattr(state_data, 'health_status'):
+ # ServiceStatus 对象
+ health_status = state_data.health_status
+ elif hasattr(state_data, 'get'):
+ # 字典对象
+ health_status = state_data.get("health_status")
+ elif hasattr(state_data, 'value'):
+ # ServiceConnectionState 枚举
+ health_status = state_data.value
+ else:
+ # 其他类型,转换为字符串
+ health_status = str(state_data)
+
+ if health_status == wait_plan.target_status:
+ logger.debug(f"[ASYNC_SHELL] [READY] Service {service_name} is ready")
+ return True
+
+ except Exception as e:
+ logger.debug(f"[ASYNC_SHELL] [ERROR] Status check failed: {e}")
+
+ # 检查超时
+ elapsed = asyncio.get_event_loop().time() - start_time
+ if elapsed > wait_plan.timeout:
+ logger.warning(f"[ASYNC_SHELL] [TIMEOUT] Waiting for service {service_name} timed out ({elapsed:.1f}s)")
+ return False
+
+ # 异步等待
+ await asyncio.sleep(wait_plan.check_interval)
+
+ except Exception as e:
+ logger.error(f"[ASYNC_SHELL] [ERROR] wait_service_async failed: {e}")
+ return False
+
+ async def _start_services_async(self, service_names: list) -> None:
+ """
+ 异步启动服务列表
+
+ 使用缓存层管理器直接从 pykv 获取服务配置
+ """
+ logger.info(f"[CONNECTION_START] [START] Starting service connection flow, service list: {service_names}")
+ logger.info(f"[CONNECTION_START] [INFO] Orchestrator type: {type(self.orchestrator)}")
+
+ if not self.orchestrator:
+ logger.warning("[CONNECTION_START] [WARN] No orchestrator, skipping service startup")
+ return
+
+ logger.info(f"[CONNECTION_START] [INFO] Orchestrator exists, checking startup methods...")
+
+ # 获取缓存层服务管理器
+ service_manager = getattr(self.registry, '_cache_service_manager', None)
+ if service_manager is None:
+ raise RuntimeError(
+ "Cache layer ServiceEntityManager is not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _cache_service_manager attribute."
+ )
+
+ for service_name in service_names:
+ try:
+ logger.info(f"[CONNECTION_START] [TRY] Attempting to start service: {service_name}")
+
+ # 检查orchestrator是否有连接方法
+ if hasattr(self.orchestrator, 'connect_service'):
+ logger.info(f"[CONNECTION_START] [FOUND] Found connect_service method, connecting service...")
+
+ # 计算全局名称,并从缓存层直接获取服务配置
+ from ..cache.naming_service import NamingService
+ naming = NamingService()
+ global_name = naming.generate_service_global_name(service_name, self.core.agent_id or "global_agent_store")
+
+ service_config = {}
+ try:
+ # 使用缓存层 ServiceEntityManager 获取服务实体(全局名)
+ service_entity = await service_manager.get_service(global_name)
+
+ logger.info(f"[CONNECTION_START] [GET] Retrieved service_entity: {service_entity}")
+
+ if service_entity:
+ # ServiceEntity 对象有 config 属性
+ if hasattr(service_entity, 'config'):
+ inner_config = service_entity.config
+ elif hasattr(service_entity, 'get'):
+ inner_config = service_entity.get("config", {})
+ else:
+ inner_config = {}
+
+ if inner_config and ('url' in inner_config or 'command' in inner_config):
+ service_config = inner_config
+ logger.info(f"[CONNECTION_START] [CONFIG] Service configuration passed to orchestrator: {service_config}")
+ else:
+ raise RuntimeError(f"[CONNECTION_START] Service configuration is invalid or empty: {inner_config}")
+ else:
+ raise RuntimeError(f"[CONNECTION_START] service_entity is empty: global_name={global_name}")
+
+ except Exception as e:
+ logger.error(f"[CONNECTION_START] [ERROR] Failed to get service configuration: {e}")
+
+ # 检查是否有异步版本
+ connect_method = getattr(self.orchestrator, 'connect_service')
+ import inspect
+ if inspect.iscoroutinefunction(connect_method):
+ logger.info(f"[CONNECTION_START] [ASYNC] connect_service is async method, calling directly...")
+ success, message = await self.orchestrator.connect_service(service_name, service_config)
+ logger.info(f"[CONNECTION_START] [RESULT] Connection result: success={success}, message={message}")
+ else:
+ logger.info(f"[CONNECTION_START] [SYNC] connect_service is sync method, calling in thread...")
+ loop = asyncio.get_running_loop()
+ success, message = await loop.run_in_executor(None, lambda: self.orchestrator.connect_service(service_name, service_config))
+ logger.info(f"[CONNECTION_START] [RESULT] Connection result: success={success}, message={message}")
+
+ logger.info(f"[CONNECTION_START] [SENT] Service {service_name} connection command sent")
+ elif hasattr(self.orchestrator, 'start_service'):
+ logger.warning(f"[CONNECTION_START] [WARN] Only sync method start_service available, may cause deadlock, skipping startup {service_name}")
+ elif hasattr(self.orchestrator, 'start_service_async'):
+ logger.info(f"[CONNECTION_START] [FOUND] Found start_service_async method, starting service...")
+ await self.orchestrator.start_service_async(service_name)
+ logger.info(f"[CONNECTION_START] [SENT] Service {service_name} startup command sent")
+ else:
+ logger.warning(f"[CONNECTION_START] [WARN] Orchestrator has no startup/connection methods, skipping {service_name}")
+ logger.info(f"[CONNECTION_START] [INFO] Orchestrator available methods: {[m for m in dir(self.orchestrator) if not m.startswith('_') and any(kw in m for kw in ['start', 'connect', 'service'])]}")
+
+ except Exception as e:
+ logger.error(f"[CONNECTION_START] [ERROR] Failed to start service {service_name}: {e}", exc_info=True)
+
+
+class ServiceManagementSyncShell:
+ """
+ 同步外壳:一次性同步转异步
+
+ 特点:
+ - 通过 Async Orchestrated Bridge 在稳定事件循环中执行
+ - 内部调用异步外壳,不再有任何同步/异步混用
+ - 完全避免_sync_to_kv的使用
+ """
+
+ def __init__(self, async_shell: ServiceManagementAsyncShell):
+ """
+ 初始化同步外壳
+
+ Args:
+ async_shell: 异步外壳实例
+ """
+ self.async_shell = async_shell
+ self._bridge = get_async_bridge()
+ logger.debug("[SYNC_SHELL] [INIT] Initializing ServiceManagementSyncShell")
+
+ def add_service(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 同步外壳:添加服务
+
+ 通过 AOB 在后台事件循环中执行异步壳,避免每次创建新循环。
+ """
+ logger.debug("[SYNC_SHELL] [START] Starting synchronous service addition")
+
+ try:
+ result = self._bridge.run(
+ self.async_shell.add_service_async(config),
+ op_name="service_management.add_service",
+ )
+
+ logger.debug(f"[SYNC_SHELL] [COMPLETE] Synchronous service addition completed: {result.get('success', False)}")
+ return result
+
+ except Exception as e:
+ logger.error(f"[SYNC_SHELL] [ERROR] Synchronous service addition failed: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "added_services": [],
+ "operations": [],
+ "total_operations": 0,
+ "successful_operations": 0,
+ }
+
+ def wait_service(self, service_name: str, timeout: float | None = None) -> bool:
+ """
+ 同步外壳:等待服务就绪
+
+ 通过 AOB 在后台事件循环中执行异步壳。
+ """
+ logger.debug(f"[SYNC_SHELL] [START] Starting synchronous service wait: {service_name}")
+
+ try:
+ result = self._bridge.run(
+ self.async_shell.wait_service_async(service_name, timeout),
+ op_name="service_management.wait_service",
+ )
+
+ logger.debug(f"[SYNC_SHELL] [COMPLETE] Synchronous service wait completed: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"[SYNC_SHELL] [ERROR] Synchronous service wait failed: {e}")
+ return False
+
+
+class ServiceManagementFactory:
+ """
+ 服务管理工厂类
+
+ 用于创建完整的服务管理实例(核心 + 外壳)
+ """
+
+ @staticmethod
+ def create_service_management(registry, orchestrator, agent_id: str = "global_agent_store") -> tuple:
+ """
+ 创建完整的服务管理实例
+
+ Returns:
+ tuple: (sync_shell, async_shell, core)
+ """
+ # 1. 创建纯同步核心
+ core = ServiceManagementCore(agent_id=agent_id)
+
+ # 2. 创建异步外壳
+ async_shell = ServiceManagementAsyncShell(core, registry, orchestrator)
+
+ # 3. 创建同步外壳
+ sync_shell = ServiceManagementSyncShell(async_shell)
+
+ logger.info("[FACTORY] [COMPLETE] Service management instance creation completed")
+
+ return sync_shell, async_shell, core
diff --git a/src/mcpstore/core/architecture/show_config_core.py b/src/mcpstore/core/architecture/show_config_core.py
new file mode 100644
index 00000000..c82ce519
--- /dev/null
+++ b/src/mcpstore/core/architecture/show_config_core.py
@@ -0,0 +1,166 @@
+"""
+ShowConfigLogicCore - show_config 的纯逻辑核心
+
+遵循 "Functional Core, Imperative Shell" 架构原则:
+- 纯同步函数
+- 不包含任何 IO 操作(no pykv, no file IO, no network IO)
+- 不调用任何异步方法
+- 不使用 await/asyncio.run()
+- 只做数据组装和计算,不执行实际操作
+
+返回格式说明:
+show_config 返回与 mcp.json 完全一致的格式:
+{
+ "mcpServers": {
+ "context7": {"url": "https://mcp.context7.com/mcp"},
+ "weather_byagent_agent1": {"url": "https://weather.api/mcp"}
+ }
+}
+
+服务名称规则:
+- Store 添加的服务:使用原始名称(如 "context7")
+- Agent 添加的服务:使用全局名称(如 "weather_byagent_agent1")
+- mcp.json 中始终使用 service_global_name
+"""
+
+import logging
+from typing import Dict, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class ShowConfigLogicCore:
+ """
+ show_config 的纯逻辑核心
+
+ 职责:
+ - 组装配置数据结构(与 mcp.json 格式完全一致)
+ - 数据格式转换
+
+ 严格约束:
+ - 所有方法必须是纯同步函数
+ - 输入:从 pykv 预读取的纯数据(字典、列表等)
+ - 输出:组装好的配置数据结构(mcpServers 格式)
+ """
+
+ def build_store_config(
+ self,
+ services_data: Dict[str, Dict[str, Any]]
+ ) -> Dict[str, Any]:
+ """
+ 构建 Store 级别配置(与 mcp.json 格式一致)
+
+ 纯同步计算,组装 Store 级别配置数据结构。
+
+ Args:
+ services_data: 从 pykv 预读取的服务数据
+ 格式: {
+ service_global_name: {
+ "config": {"url": "..."} 或 {"command": "...", "args": [...]}
+ }
+ }
+
+ Returns:
+ 与 mcp.json 格式一致的配置:
+ {
+ "mcpServers": {
+ "context7": {"url": "..."},
+ "weather_byagent_agent1": {"url": "..."}
+ }
+ }
+ """
+ mcp_servers = {}
+
+ for service_global_name, service_info in services_data.items():
+ # 提取服务配置(url/command/args 等)
+ config = service_info.get("config", {})
+ if config:
+ # 使用全局名称作为 key(与 mcp.json 一致)
+ mcp_servers[service_global_name] = config
+
+ return {"mcpServers": mcp_servers}
+
+ def build_agent_config(
+ self,
+ agent_id: str,
+ services_data: Dict[str, Dict[str, Any]]
+ ) -> Dict[str, Any]:
+ """
+ 构建 Agent 级别配置(与 mcp.json 格式一致)
+
+ 纯同步计算,组装 Agent 级别配置数据结构。
+ 只返回属于该 Agent 的服务。
+
+ Args:
+ agent_id: Agent ID
+ services_data: 从 pykv 预读取的服务数据
+ 格式: {
+ service_global_name: {
+ "config": {"url": "..."} 或 {"command": "...", "args": [...]}
+ }
+ }
+
+ Returns:
+ 与 mcp.json 格式一致的配置:
+ {
+ "mcpServers": {
+ "local_service_name": {"url": "..."}
+ }
+ }
+ """
+ mcp_servers = {}
+
+ for service_local_name, service_info in services_data.items():
+ config = service_info.get("config", {})
+ if config:
+ mcp_servers[service_local_name] = config
+
+ return {"mcpServers": mcp_servers}
+
+ def build_error_response(
+ self,
+ error_message: str,
+ agent_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 构建错误响应
+
+ 纯同步计算,构建标准化的错误响应结构。
+
+ Args:
+ error_message: 错误信息
+ agent_id: 可选的 Agent ID(仅用于日志,不包含在返回中)
+
+ Returns:
+ 标准化的错误响应结构
+ """
+ return {
+ "error": error_message,
+ "mcpServers": {}
+ }
+
+ def extract_service_config(
+ self,
+ service_entity: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """
+ 从服务实体中提取配置
+
+ 纯同步计算,从 ServiceEntity 中提取 mcp.json 格式的配置。
+
+ ServiceEntity 结构:
+ {
+ "service_global_name": "weather_byagent_agent1",
+ "service_original_name": "weather",
+ "source_agent": "agent1",
+ "config": {"url": "https://weather.api/mcp"},
+ "added_time": 1234567890
+ }
+
+ Args:
+ service_entity: 从 pykv 获取的服务实体
+
+ Returns:
+ 服务配置(url/command/args 等)
+ """
+ return service_entity.get("config", {})
diff --git a/src/mcpstore/core/architecture/show_config_shell.py b/src/mcpstore/core/architecture/show_config_shell.py
new file mode 100644
index 00000000..7b5a6f8a
--- /dev/null
+++ b/src/mcpstore/core/architecture/show_config_shell.py
@@ -0,0 +1,287 @@
+"""
+ShowConfigAsyncShell - show_config 的异步外壳
+
+遵循 "Functional Core, Imperative Shell" 架构原则:
+- 负责所有 IO 操作(pykv 读取)
+- 只使用 await,不使用 asyncio.run()
+- 在现有事件循环中执行
+- 调用纯逻辑核心进行数据处理
+
+返回格式说明:
+show_config 返回与 mcp.json 完全一致的格式:
+{
+ "mcpServers": {
+ "context7": {"url": "https://mcp.context7.com/mcp"},
+ "weather_byagent_agent1": {"url": "https://weather.api/mcp"}
+ }
+}
+
+服务名称规则:
+- Store 添加的服务:使用原始名称(如 "context7")
+- Agent 添加的服务:使用全局名称(如 "weather_byagent_agent1")
+- mcp.json 中始终使用 service_global_name
+"""
+
+import logging
+from typing import Dict, Any, TYPE_CHECKING
+
+from .show_config_core import ShowConfigLogicCore
+
+if TYPE_CHECKING:
+ from mcpstore.core.cache.cache_layer_manager import CacheLayerManager
+
+logger = logging.getLogger(__name__)
+
+
+class ShowConfigAsyncShell:
+ """
+ show_config 的异步外壳
+
+ 职责:
+ - 从 pykv 读取所有需要的数据
+ - 调用纯逻辑核心处理数据
+ - 返回与 mcp.json 格式完全一致的配置
+
+ 严格约束:
+ - 只使用 await,不使用 asyncio.run()
+ - 所有 pykv 操作在此层完成
+ - 不包含业务逻辑计算
+ """
+
+ def __init__(self, cache_layer: 'CacheLayerManager', namespace: str = "default"):
+ """
+ 初始化异步外壳
+
+ Args:
+ cache_layer: CacheLayerManager 实例
+ namespace: 命名空间
+ """
+ self._cache_layer = cache_layer
+ self._namespace = namespace
+ self._logic_core = ShowConfigLogicCore()
+
+ async def show_store_config_async(self) -> Dict[str, Any]:
+ """
+ 异步获取 Store 级别配置(与 mcp.json 格式一致)
+
+ 执行流程:
+ 1. 从 pykv 异步读取所有服务实体
+ 2. 提取服务配置(使用 service_global_name 作为 key)
+ 3. 调用纯逻辑核心组装 mcpServers 格式
+
+ Returns:
+ 与 mcp.json 格式一致的配置:
+ {
+ "mcpServers": {
+ "context7": {"url": "..."},
+ "weather_byagent_agent1": {"url": "..."}
+ }
+ }
+ """
+ try:
+ logger.info("[SHOW_CONFIG_SHELL] [STORE] Store level: starting to get configuration")
+
+ # Step 1: 从 pykv 读取所有服务实体
+ services_data = await self._read_all_services_data_async()
+
+ # Step 2: 调用纯逻辑核心组装配置
+ result = self._logic_core.build_store_config(services_data)
+
+ logger.info(
+ f"[SHOW_CONFIG_SHELL] Store level configuration retrieval completed: "
+ f"services={len(result.get('mcpServers', {}))}"
+ )
+
+ return result
+
+ except Exception as e:
+ logger.error(f"[SHOW_CONFIG_SHELL] [ERROR] Store level configuration retrieval failed: {e}")
+ return self._logic_core.build_error_response(
+ f"Failed to show store config: {str(e)}"
+ )
+
+ async def show_agent_config_async(self, agent_id: str) -> Dict[str, Any]:
+ """
+ 异步获取 Agent 级别配置(与 mcp.json 格式一致)
+
+ 执行流程:
+ 1. 从 pykv 异步检查 Agent 是否存在
+ 2. 从 pykv 异步读取该 Agent 的服务数据
+ 3. 调用纯逻辑核心组装 mcpServers 格式
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 与 mcp.json 格式一致的配置:
+ {
+ "mcpServers": {
+ "weather_byagent_agent1": {"url": "..."}
+ }
+ }
+ """
+ try:
+ logger.info(f"[SHOW_CONFIG_SHELL] [AGENT] Agent level: starting to get Agent {agent_id} configuration")
+
+ # Step 1: 从 pykv 检查 Agent 是否存在
+ agent_exists = await self._check_agent_exists_async(agent_id)
+ if not agent_exists:
+ logger.warning(f"[SHOW_CONFIG_SHELL] [WARN] Agent {agent_id} does not exist, returning empty configuration")
+ return {"mcpServers": {}}
+
+ # Step 2: 从 pykv 读取该 Agent 的服务数据
+ services_data = await self._read_agent_services_data_async(agent_id)
+
+ # Step 3: 调用纯逻辑核心组装配置
+ result = self._logic_core.build_agent_config(agent_id, services_data)
+
+ logger.info(
+ f"[SHOW_CONFIG_SHELL] Agent {agent_id} configuration retrieval completed: "
+ f"services={len(result.get('mcpServers', {}))}"
+ )
+
+ return result
+
+ except Exception as e:
+ logger.error(f"[SHOW_CONFIG_SHELL] [ERROR] Agent {agent_id} configuration retrieval failed: {e}")
+ return self._logic_core.build_error_response(
+ f"Failed to show agent config: {str(e)}",
+ agent_id=agent_id
+ )
+
+ async def _read_all_services_data_async(self) -> Dict[str, Dict[str, Any]]:
+ """
+ 从 pykv 异步读取所有服务数据
+
+ 遵循 pykv 唯一真相数据源原则,直接从 pykv 实体层读取。
+ 使用 service_global_name 作为 key(与 mcp.json 一致)。
+
+ Returns:
+ 所有服务的配置数据
+ 格式: {service_global_name: {"config": {...}}}
+ """
+ services_data = {}
+
+ try:
+ # 从 pykv 实体层读取所有服务实体
+ all_services = await self._cache_layer.get_all_entities_async("services")
+
+ logger.debug(f"[SHOW_CONFIG_SHELL] [READ] Read {len(all_services)} service entities from pykv")
+
+ # 提取每个服务的配置
+ for global_name, service_entity in all_services.items():
+ # 使用 service_global_name 作为 key(与 mcp.json 一致)
+ service_global_name = service_entity.get("service_global_name")
+ if not service_global_name:
+ # 如果实体中没有 service_global_name,使用 pykv 的 key
+ service_global_name = global_name
+
+ # 提取服务配置
+ config = self._logic_core.extract_service_config(service_entity)
+
+ if config:
+ services_data[service_global_name] = {"config": config}
+
+ logger.debug(f"[SHOW_CONFIG_SHELL] [EXTRACT] Extracted {len(services_data)} service configurations")
+
+ return services_data
+
+ except Exception as e:
+ logger.error(f"[SHOW_CONFIG_SHELL] [ERROR] Failed to read all service data: {e}")
+ raise
+
+ async def _read_agent_services_data_async(
+ self,
+ agent_id: str
+ ) -> Dict[str, Dict[str, Any]]:
+ """
+ 从 pykv 异步读取指定 Agent 的服务数据
+
+ 使用 service_global_name 作为 key(与 mcp.json 一致)。
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 该 Agent 的服务配置数据
+ 格式: {service_global_name: {"config": {...}}}
+ """
+ services_data = {}
+
+ try:
+ # 从 pykv 实体层读取所有服务实体
+ all_services = await self._cache_layer.get_all_entities_async("services")
+
+ # 过滤属于指定 agent_id 的服务
+ for global_name, service_entity in all_services.items():
+ # 获取服务所属的 agent_id
+ entity_agent_id = service_entity.get("source_agent")
+ if not entity_agent_id:
+ # 尝试从 global_name 解析
+ # global_name 格式: service_name_byagent_agent_id
+ if "_byagent_" in global_name:
+ _, entity_agent_id = global_name.rsplit("_byagent_", 1)
+ else:
+ entity_agent_id = "global_agent_store"
+
+ if entity_agent_id == agent_id:
+ # Agent 视角使用本地名称(service_original_name)作为 key
+ service_local_name = service_entity.get("service_original_name") or service_entity.get("service_name")
+ if not service_local_name:
+ # 回退:从全局名称中剥离 _byagent 后缀
+ service_local_name = global_name.split("_byagent_")[0] if "_byagent_" in global_name else global_name
+
+ # 提取服务配置
+ config = self._logic_core.extract_service_config(service_entity)
+
+ if config:
+ services_data[service_local_name] = {"config": config}
+
+ logger.debug(
+ f"[SHOW_CONFIG_SHELL] Agent {agent_id} service data: "
+ f"{len(services_data)} services"
+ )
+
+ return services_data
+
+ except Exception as e:
+ logger.error(f"[SHOW_CONFIG_SHELL] [ERROR] Failed to read Agent {agent_id} service data: {e}")
+ raise
+
+ async def _check_agent_exists_async(self, agent_id: str) -> bool:
+ """
+ 从 pykv 异步检查 Agent 是否存在
+
+ 通过检查是否有属于该 Agent 的服务来判断 Agent 是否存在。
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ Agent 是否存在
+ """
+ try:
+ # 方法1: 检查 Agent 实体是否存在
+ agent_entity = await self._cache_layer.get_entity("agents", agent_id)
+ if agent_entity:
+ return True
+
+ # 方法2: 检查是否有属于该 Agent 的服务
+ all_services = await self._cache_layer.get_all_entities_async("services")
+ for global_name, service_entity in all_services.items():
+ entity_agent_id = service_entity.get("source_agent")
+ if not entity_agent_id and "_byagent_" in global_name:
+ _, entity_agent_id = global_name.rsplit("_byagent_", 1)
+
+ if entity_agent_id == agent_id:
+ return True
+
+ # 方法3: 特殊处理 global_agent_store
+ if agent_id == "global_agent_store":
+ return True
+
+ return False
+
+ except Exception as e:
+ logger.error(f"[SHOW_CONFIG_SHELL] [ERROR] Failed to check if Agent {agent_id} exists: {e}")
+ raise
diff --git a/src/mcpstore/core/bridge/__init__.py b/src/mcpstore/core/bridge/__init__.py
new file mode 100644
index 00000000..46a7b7c2
--- /dev/null
+++ b/src/mcpstore/core/bridge/__init__.py
@@ -0,0 +1,17 @@
+"""
+Async Orchestrated Bridge (AOB)
+
+为同步 API 提供统一的异步执行桥梁。
+"""
+
+from .async_orchestrated_bridge import (
+ AsyncOrchestratedBridge,
+ get_async_bridge,
+ close_async_bridge,
+)
+
+__all__ = [
+ "AsyncOrchestratedBridge",
+ "get_async_bridge",
+ "close_async_bridge",
+]
diff --git a/src/mcpstore/core/bridge/async_orchestrated_bridge.py b/src/mcpstore/core/bridge/async_orchestrated_bridge.py
new file mode 100644
index 00000000..828737d0
--- /dev/null
+++ b/src/mcpstore/core/bridge/async_orchestrated_bridge.py
@@ -0,0 +1,286 @@
+"""
+Async Orchestrated Bridge (AOB)
+
+为同步 API 提供一个持久化的异步执行通道,避免在多个事件循环之间切换导致的冲突。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+import time
+import uuid
+from concurrent.futures import Future, TimeoutError as FutureTimeoutError
+from dataclasses import dataclass
+from typing import Any, Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class BridgeTaskHandle:
+ """桥接后台任务的同步控制句柄。"""
+
+ _bridge: "AsyncOrchestratedBridge"
+ _task_id: str
+
+ def cancel(self) -> None:
+ self._bridge._request_cancel_background_task(self._task_id)
+
+ def done(self) -> bool:
+ entry = self._bridge._background_tasks.get(self._task_id)
+ if not entry:
+ return True
+ return entry.future.done()
+
+
+class AsyncOrchestratedBridge:
+ """进程级的异步执行桥梁。"""
+
+ def __init__(self, default_timeout: float = 60.0):
+ self._default_timeout = default_timeout
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._thread: Optional[threading.Thread] = None
+ self._loop_lock = threading.RLock()
+ self._stop_event = threading.Event()
+ self._active_calls: Dict[str, Dict[str, Any]] = {}
+ self._background_tasks: Dict[str, "_BackgroundEntry"] = {}
+ self._heartbeat_task: Optional[asyncio.Task[Any]] = None
+ self._heartbeat_interval = 0.05
+
+ # ------------------------------------------------------------------ #
+ # 外部接口
+ # ------------------------------------------------------------------ #
+
+ def run(
+ self,
+ coro: asyncio.coroutines.Coroutine[Any, Any, Any],
+ *,
+ timeout: Optional[float] = None,
+ op_name: str = "unknown",
+ ) -> Any:
+ """
+ 在稳定事件循环中运行协程。
+
+ Args:
+ coro: 要执行的协程
+ timeout: 超时时间(秒),默认使用实例的 default_timeout
+ op_name: 操作名称,用于日志/诊断
+ allow_async: 在已有事件循环中是否允许直接 await(默认 False,保证同步 API 不被异步上下文误用)
+ """
+ if timeout is None:
+ timeout = self._default_timeout
+
+ if self._in_async_context():
+ raise RuntimeError(
+ f"检测到正在运行的事件循环:请使用 {op_name}_async() 接口。"
+ )
+
+ loop = self._ensure_loop()
+ call_id = self._register_call(op_name)
+
+ async def runner():
+ return await asyncio.wait_for(coro, timeout=timeout)
+
+ future = asyncio.run_coroutine_threadsafe(runner(), loop)
+ try:
+ result = future.result(timeout=timeout)
+ return result
+ except FutureTimeoutError as exc:
+ logger.error("[AOB] %s timed out after %.1fs", op_name, timeout)
+ future.cancel()
+ raise TimeoutError(f"{op_name} timed out after {timeout}s") from exc
+ finally:
+ self._unregister_call(call_id)
+
+ def create_background_task(
+ self,
+ coro: asyncio.coroutines.Coroutine[Any, Any, Any],
+ *,
+ op_name: str = "background",
+ ) -> BridgeTaskHandle:
+ """
+ 在后台循环中启动协程,返回同步侧的控制句柄。
+
+ 该接口适用于健康检查、监控等长期任务。
+ """
+ loop = self._ensure_loop()
+ task_id = self._generate_task_id(op_name)
+ entry = _BackgroundEntry(op_name=op_name, created=time.time())
+ self._background_tasks[task_id] = entry
+
+ async def starter():
+ async def wrapped():
+ try:
+ await coro
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("[AOB] Background task %s crashed", op_name)
+ raise
+
+ entry.task = asyncio.create_task(wrapped(), name=f"AOB:{op_name}")
+
+ def _cleanup(task: asyncio.Task[Any]):
+ def _remove():
+ self._background_tasks.pop(task_id, None)
+
+ try:
+ task.result()
+ except asyncio.CancelledError:
+ logger.info("[AOB] Background task %s cancelled", op_name)
+ except Exception:
+ logger.exception("[AOB] Background task %s finished with error", op_name)
+ finally:
+ loop = self._loop
+ if loop and loop.is_running():
+ loop.call_soon_threadsafe(_remove)
+ else:
+ _remove()
+
+ entry.task.add_done_callback(_cleanup)
+
+ entry.future = asyncio.run_coroutine_threadsafe(starter(), loop)
+ return BridgeTaskHandle(self, task_id)
+
+ def inspect_active_calls(self) -> Dict[str, Dict[str, Any]]:
+ """获取当前活跃的同步调用信息。"""
+ return dict(self._active_calls)
+
+ def close(self) -> None:
+ """停止后台循环并清理资源。"""
+ with self._loop_lock:
+ if not self._loop:
+ return
+ logger.info("[AOB] shutting down bridge loop")
+ self._stop_event.set()
+ loop = self._loop
+ loop.call_soon_threadsafe(loop.stop)
+ if self._thread and self._thread.is_alive():
+ self._thread.join(timeout=2)
+ self._loop = None
+ self._thread = None
+
+ # ------------------------------------------------------------------ #
+ # 内部实现
+ # ------------------------------------------------------------------ #
+
+ def _ensure_loop(self) -> asyncio.AbstractEventLoop:
+ with self._loop_lock:
+ if self._loop and self._loop.is_running():
+ return self._loop
+ self._stop_event.clear()
+ loop_ready = threading.Event()
+
+ def _run_loop():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self._loop = loop
+ self._heartbeat_task = loop.create_task(
+ self._loop_heartbeat(),
+ name="AOB:heartbeat",
+ )
+ self._heartbeat_task.add_done_callback(
+ lambda task: logger.debug("[AOB] Heartbeat stopped: %s", task)
+ )
+ loop_ready.set()
+ logger.info("[AOB] event loop started (thread=%s)", threading.current_thread().name)
+ loop.run_forever()
+ if self._heartbeat_task and not self._heartbeat_task.done():
+ self._heartbeat_task.cancel()
+ try:
+ loop.run_until_complete(self._heartbeat_task)
+ except Exception:
+ pass
+ self._heartbeat_task = None
+ loop.close()
+ logger.info("[AOB] event loop stopped")
+
+ self._thread = threading.Thread(target=_run_loop, name="async_bridge_loop", daemon=True)
+ self._thread.start()
+ if not loop_ready.wait(timeout=5):
+ raise RuntimeError("Async bridge loop failed to start")
+ return self._loop # type: ignore[return-value]
+
+ def _register_call(self, op_name: str) -> str:
+ call_id = f"{op_name}:{uuid.uuid4()}"
+ self._active_calls[call_id] = {
+ "operation": op_name,
+ "start_time": time.time(),
+ "thread": threading.current_thread().name,
+ }
+ return call_id
+
+ def _unregister_call(self, call_id: str) -> None:
+ self._active_calls.pop(call_id, None)
+
+ def _request_cancel_background_task(self, task_id: str) -> None:
+ entry = self._background_tasks.get(task_id)
+ if not entry:
+ return
+ loop = self._loop
+ if not loop or not loop.is_running():
+ return
+
+ def _cancel():
+ if entry.task and not entry.task.done():
+ entry.task.cancel()
+
+ loop.call_soon_threadsafe(_cancel)
+
+ def _generate_task_id(self, op_name: str) -> str:
+ return f"{op_name}:{uuid.uuid4()}"
+
+ async def _loop_heartbeat(self) -> None:
+ """
+ Keep the async loop from idling forever on selectors.
+
+ Some environments don't deliver selector wakeups reliably when the loop
+ is completely idle, so we yield periodically to guarantee forward
+ progress for run_coroutine_threadsafe() calls.
+ """
+ try:
+ while not self._stop_event.is_set():
+ await asyncio.sleep(self._heartbeat_interval)
+ except asyncio.CancelledError:
+ pass
+
+ @staticmethod
+ def _in_async_context() -> bool:
+ try:
+ asyncio.get_running_loop()
+ return True
+ except RuntimeError:
+ return False
+
+
+@dataclass
+class _BackgroundEntry:
+ op_name: str
+ created: float
+ future: Future[None] | None = None
+ task: asyncio.Task[Any] | None = None
+
+
+_GLOBAL_BRIDGE: Optional[AsyncOrchestratedBridge] = None
+_GLOBAL_LOCK = threading.Lock()
+
+
+def get_async_bridge() -> AsyncOrchestratedBridge:
+ """获取全局 AOB 实例。"""
+ global _GLOBAL_BRIDGE
+ if _GLOBAL_BRIDGE is None:
+ with _GLOBAL_LOCK:
+ if _GLOBAL_BRIDGE is None:
+ _GLOBAL_BRIDGE = AsyncOrchestratedBridge()
+ return _GLOBAL_BRIDGE
+
+
+def close_async_bridge() -> None:
+ """关闭全局 AOB。"""
+ global _GLOBAL_BRIDGE
+ with _GLOBAL_LOCK:
+ if _GLOBAL_BRIDGE is not None:
+ _GLOBAL_BRIDGE.close()
+ _GLOBAL_BRIDGE = None
diff --git a/src/mcpstore/core/cache/__init__.py b/src/mcpstore/core/cache/__init__.py
new file mode 100644
index 00000000..fbc3bea8
--- /dev/null
+++ b/src/mcpstore/core/cache/__init__.py
@@ -0,0 +1,50 @@
+"""
+缓存架构模块
+
+提供三层缓存架构的实现:
+- 实体层 (Entity Layer)
+- 关系层 (Relationship Layer)
+- 状态层 (State Layer)
+"""
+
+from .cache_layer_manager import CacheLayerManager
+from .models import (
+ ServiceEntity,
+ ToolEntity,
+ AgentEntity,
+ StoreConfig,
+ ServiceRelationItem,
+ AgentServiceRelation,
+ ToolRelationItem,
+ ServiceToolRelation,
+ ToolStatusItem,
+ ServiceStatus,
+)
+from .naming_service import NamingService
+from .relationship_manager import RelationshipManager
+from .service_entity_manager import ServiceEntityManager
+from .state_manager import StateManager
+from .tool_entity_manager import ToolEntityManager
+
+__all__ = [
+ # 管理器
+ "CacheLayerManager",
+ "NamingService",
+ "ServiceEntityManager",
+ "ToolEntityManager",
+ "RelationshipManager",
+ "StateManager",
+ # 实体层模型
+ "ServiceEntity",
+ "ToolEntity",
+ "AgentEntity",
+ "StoreConfig",
+ # 关系层模型
+ "ServiceRelationItem",
+ "AgentServiceRelation",
+ "ToolRelationItem",
+ "ServiceToolRelation",
+ # 状态层模型
+ "ToolStatusItem",
+ "ServiceStatus",
+]
diff --git a/src/mcpstore/core/cache/cache_layer_manager.py b/src/mcpstore/core/cache/cache_layer_manager.py
new file mode 100644
index 00000000..6a9e392e
--- /dev/null
+++ b/src/mcpstore/core/cache/cache_layer_manager.py
@@ -0,0 +1,998 @@
+"""
+缓存层管理器
+
+负责管理三层缓存架构的访问和操作:
+- 实体层 (Entity Layer)
+- 关系层 (Relationship Layer)
+- 状态层 (State Layer)
+"""
+
+import asyncio
+import logging
+import time
+import copy
+from typing import Any, Dict, Optional, List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from key_value.aio.protocols import AsyncKeyValue
+
+logger = logging.getLogger(__name__)
+
+
+class CacheLayerManager:
+ """
+ 缓存层管理器
+
+ 使用 py-key-value (pyvk) 的 Collection 机制实现三层数据隔离。
+ Collection 命名格式: {namespace}:{layer}:{type}
+ """
+
+ _EMPTY_LOG_INTERVAL_SECONDS = 60.0
+ _SCAN_LOG_INTERVAL_SECONDS = 60.0
+
+ def __init__(self, kv_store: 'AsyncKeyValue', namespace: str = "default"):
+ """
+ 初始化缓存层管理器
+
+ Args:
+ kv_store: pykv 的 AsyncKeyValue 实例
+ namespace: 命名空间,默认为 "default"
+ """
+ self._kv_store = kv_store
+ self._namespace = namespace
+ # 所有 pykv 调用统一通过 AOB 所属的事件循环执行,避免跨 loop Future 冲突
+ try:
+ from mcpstore.core.bridge import get_async_bridge # 延迟导入避免循环依赖
+ self._bridge = get_async_bridge()
+ except Exception:
+ self._bridge = None
+ self._last_empty_log: Dict[str, float] = {}
+ self._last_scan_log: Dict[str, float] = {}
+ self._last_state_snapshot: Dict[str, Any] = {}
+ logger.debug(f"[CACHE] [INIT] Initializing CacheLayerManager, namespace: {namespace}")
+
+ async def _await_in_bridge(self, coro, op_name: str):
+ """
+ 确保在 AOB 事件循环中执行 pykv 协程,防止不同事件循环的锁冲突。
+ """
+ if self._bridge is None:
+ return await coro
+
+ bridge_loop = getattr(self._bridge, "_loop", None)
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ # 已在桥接循环内,直接执行
+ if bridge_loop and running_loop is bridge_loop:
+ return await coro
+
+ # 无事件循环(同步调用场景)
+ if running_loop is None:
+ return self._bridge.run(coro, op_name=op_name)
+
+ # 其他事件循环内,切换到 AOB loop
+ return await asyncio.to_thread(self._bridge.run, coro, op_name=op_name)
+
+ # ==================== Collection 命名方法 ====================
+
+ def _get_entity_collection(self, entity_type: str) -> str:
+ """
+ 生成实体层 Collection 名称
+
+ 格式: {namespace}:entity:{entity_type}
+
+ Args:
+ entity_type: 实体类型,如 "services", "tools", "agents", "store"
+
+ Returns:
+ Collection 名称
+ """
+ return f"{self._namespace}:entity:{entity_type}"
+
+ def _get_relation_collection(self, relation_type: str) -> str:
+ """
+ 生成关系层 Collection 名称
+
+ 格式: {namespace}:relations:{relation_type}
+
+ Args:
+ relation_type: 关系类型,如 "agent_services", "service_tools"
+
+ Returns:
+ Collection 名称
+ """
+ return f"{self._namespace}:relations:{relation_type}"
+
+ def _get_state_collection(self, state_type: str) -> str:
+ """
+ 生成状态层 Collection 名称
+
+ 格式: {namespace}:state:{state_type}
+
+ Args:
+ state_type: 状态类型,如 "service_status"
+
+ Returns:
+ Collection 名称
+ """
+ return f"{self._namespace}:state:{state_type}"
+
+ def _log_empty_collection(self, collection: str):
+ """限制空集合调试日志的打印频率,避免刷屏"""
+ now = time.time()
+ last_logged = self._last_empty_log.get(collection, 0.0)
+ if now - last_logged >= self._EMPTY_LOG_INTERVAL_SECONDS:
+ logger.debug(f"[CACHE] [EMPTY] Collection is empty: collection={collection}")
+ self._last_empty_log[collection] = now
+
+ def _should_log_scan(self, key: str) -> bool:
+ """控制扫描日志输出频率"""
+ now = time.time()
+ last_logged = self._last_scan_log.get(key, 0.0)
+ if now - last_logged >= self._SCAN_LOG_INTERVAL_SECONDS:
+ self._last_scan_log[key] = now
+ return True
+ return False
+
+ def _has_state_changed(self, key: str, value: Any) -> bool:
+ """检查状态是否发生变化(用于减少重复日志)"""
+ sentinel = object()
+ previous = self._last_state_snapshot.get(key, sentinel)
+ if previous is sentinel:
+ self._last_state_snapshot[key] = copy.deepcopy(value)
+ return True
+ if previous != value:
+ self._last_state_snapshot[key] = copy.deepcopy(value)
+ return True
+ return False
+
+ # ==================== 实体层操作 ====================
+
+ async def put_entity(
+ self,
+ entity_type: str,
+ key: str,
+ value: Dict[str, Any]
+ ) -> None:
+ """
+ 存储实体到实体层
+
+ Args:
+ entity_type: 实体类型
+ key: 实体的唯一标识
+ value: 实体数据(必须是字典)
+
+ Raises:
+ ValueError: 如果 value 不是字典类型
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if not isinstance(value, dict):
+ raise ValueError(
+ f"实体值必须是字典类型,实际类型: {type(value).__name__}. "
+ f"entity_type={entity_type}, key={key}"
+ )
+
+ collection = self._get_entity_collection(entity_type)
+ logger.debug(
+ f"[CACHE] put_entity: collection={collection}, key={key}, "
+ f"entity_type={entity_type}, kv_store instance={id(self._kv_store)}"
+ )
+
+ try:
+ logger.debug(f"[CACHE] [PUT] Calling put: key={key}, collection={collection}, value={value}")
+ await self._await_in_bridge(
+ self._kv_store.put(key, value, collection=collection),
+ f"cache.put_entity.{entity_type}"
+ )
+
+ # 调试:检查写入后的内部状态
+ if hasattr(self._kv_store, '_cache'):
+ cache_keys = list(self._kv_store._cache.keys())
+ logger.debug(f"[CACHE] [WRITE] After write, _cache contains {len(cache_keys)} keys: {cache_keys}")
+ # 检查具体写入的数据
+ if collection in self._kv_store._cache:
+ logger.debug(f"[CACHE] [DATA] Collection {collection} data: {self._kv_store._cache[collection]}")
+ else:
+ logger.debug(f"[CACHE] [MISS] Collection {collection} does not exist in _cache")
+
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to store entity: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to store entity: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_entity(
+ self,
+ entity_type: str,
+ key: str
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 从实体层获取实体
+
+ Args:
+ entity_type: 实体类型
+ key: 实体的唯一标识
+
+ Returns:
+ 实体数据字典,如果不存在返回 None
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if entity_type == "client_configs":
+ raise RuntimeError("entity_type 'client_configs' is deprecated, please use 'clients'")
+
+ collection = self._get_entity_collection(entity_type)
+ logger.debug(
+ f"[CACHE] get_entity: collection={collection}, key={key}, "
+ f"entity_type={entity_type}"
+ )
+
+ try:
+ result = await self._await_in_bridge(
+ self._kv_store.get(key, collection=collection),
+ f"cache.get_entity.{entity_type}"
+ )
+ return result
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to get entity: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to get entity: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def delete_entity(self, entity_type: str, key: str) -> None:
+ """
+ 从实体层删除实体
+
+ Args:
+ entity_type: 实体类型
+ key: 实体的唯一标识
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ collection = self._get_entity_collection(entity_type)
+ logger.debug(
+ f"[CACHE] delete_entity: collection={collection}, key={key}, "
+ f"entity_type={entity_type}"
+ )
+
+ try:
+ await self._await_in_bridge(
+ self._kv_store.delete(key, collection=collection),
+ f"cache.delete_entity.{entity_type}"
+ )
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to delete entity: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to delete entity: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_many_entities(
+ self,
+ entity_type: str,
+ keys: List[str]
+ ) -> List[Optional[Dict[str, Any]]]:
+ """
+ 批量获取实体
+
+ Args:
+ entity_type: 实体类型
+ keys: 实体的唯一标识列表
+
+ Returns:
+ 实体数据列表,不存在的实体返回 None
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if entity_type == "client_configs":
+ raise RuntimeError("entity_type 'client_configs' is deprecated, please use 'clients'")
+
+ collection = self._get_entity_collection(entity_type)
+ logger.debug(
+ f"[CACHE] get_many_entities: collection={collection}, "
+ f"keys_count={len(keys)}, entity_type={entity_type}"
+ )
+
+ try:
+ results = await self._await_in_bridge(
+ self._kv_store.get_many(keys, collection=collection),
+ f"cache.get_many_entities.{entity_type}"
+ )
+ return results
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to get many entities: collection={collection}, "
+ f"keys_count={len(keys)}, error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to get many entities: collection={collection}, "
+ f"keys_count={len(keys)}, error={e}"
+ ) from e
+
+ def get_all_entities_sync(self, entity_type: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 同步获取指定类型的所有实体
+
+ 这个方法严格遵守核心原则:
+ - 通过 pykv 缓存读取,不绕过任何接口
+ - 使用同步异步转换在最外层
+ - 保持纯计算和IO操作的分离
+
+ Args:
+ entity_type: 实体类型
+
+ Returns:
+ Dict[str, Dict[str, Any]]: 实体数据字典 {key: entity_data}
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ logger.debug(f"[CACHE] get_all_entities_sync: entity_type={entity_type}")
+
+ if entity_type == "client_configs":
+ raise RuntimeError("entity_type 'client_configs' is deprecated, please use 'clients'")
+
+ async def _get_all_entities_async():
+ """异步内部方法:只使用 await"""
+ entities: Dict[str, Dict[str, Any]] = {}
+ collection = self._get_entity_collection(entity_type)
+ logger.debug(f"[CACHE] [GET] _get_all_entities_async: collection={collection}")
+
+ entity_keys = await self._kv_store.keys(collection=collection)
+ logger.debug(f"[CACHE] [GET] Retrieved {len(entity_keys)} keys from collection={collection}")
+
+ if not entity_keys:
+ return {}
+
+ results = await self._kv_store.get_many(entity_keys, collection=collection)
+
+ for i, key in enumerate(entity_keys):
+ if i < len(results) and results[i] is not None:
+ entities[key] = results[i]
+
+ logger.debug(f"[CACHE] [GET] _get_all_entities_async completed: found {len(entities)} entities")
+ return entities
+
+ try:
+ if self._bridge:
+ return self._bridge.run(_get_all_entities_async(), op_name=f"cache.get_all_entities_sync.{entity_type}")
+ # 回退:无桥接时使用 asyncio.run(MemoryStore 场景)
+ return asyncio.run(_get_all_entities_async())
+ except Exception as e:
+ logger.error(f"[CACHE] [ERROR] Failed to get all entities synchronously: entity_type={entity_type}, error={e}")
+ raise RuntimeError(f"Failed to get all entities synchronously: entity_type={entity_type}, error={e}") from e
+
+ async def get_all_entities_async(self, entity_type: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 异步获取指定类型的所有实体
+
+ 遵循核心原则:
+ - 只使用 await,不使用 asyncio.run()
+ - 在现有事件循环中执行
+ - 通过 pykv 接口读取数据
+ - 正确传递 collection 参数给 keys() 方法
+
+ Args:
+ entity_type: 实体类型
+
+ Returns:
+ Dict[str, Dict[str, Any]]: 实体数据字典 {key: entity_data}
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if entity_type == "client_configs":
+ raise RuntimeError("entity_type 'client_configs' is deprecated, please use 'clients'")
+
+ log_key = f"entity_scan:{entity_type}"
+ log_scan = self._should_log_scan(log_key)
+ if log_scan:
+ logger.debug(f"[CACHE] get_all_entities_async: entity_type={entity_type}")
+
+ async def _read():
+ collection = self._get_entity_collection(entity_type)
+ if log_scan:
+ logger.debug(f"[CACHE] get_all_entities_async: collection={collection}, entity_type={entity_type}")
+
+ entity_keys = await self._kv_store.keys(collection=collection)
+
+ if log_scan:
+ logger.debug(f"[CACHE] [GET] Retrieved {len(entity_keys)} keys from collection={collection}")
+
+ if not entity_keys:
+ self._log_empty_collection(collection)
+ return {}
+
+ # 批量获取实体数据
+ results = await self._kv_store.get_many(entity_keys, collection=collection)
+
+ entities: Dict[str, Dict[str, Any]] = {}
+ for i, key in enumerate(entity_keys):
+ if i < len(results) and results[i] is not None:
+ entities[key] = results[i]
+
+ if log_scan:
+ logger.debug(f"[CACHE] [GET] get_all_entities_async completed: found {len(entities)} entities")
+
+ return entities
+
+ try:
+ return await self._await_in_bridge(_read(), f"cache.get_all_entities_async.{entity_type}")
+ except Exception as e:
+ logger.error(f"[CACHE] [ERROR] Failed to get all entities asynchronously: entity_type={entity_type}, error={e}")
+ raise RuntimeError(f"Failed to get all entities asynchronously: entity_type={entity_type}, error={e}") from e
+
+ # ==================== 关系层操作 ====================
+
+ async def put_relation(
+ self,
+ relation_type: str,
+ key: str,
+ value: Dict[str, Any]
+ ) -> None:
+ """
+ 存储关系到关系层
+
+ Args:
+ relation_type: 关系类型
+ key: 关系的唯一标识
+ value: 关系数据(必须是字典)
+
+ Raises:
+ ValueError: 如果 value 不是字典类型
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if not isinstance(value, dict):
+ raise ValueError(
+ f"关系值必须是字典类型,实际类型: {type(value).__name__}. "
+ f"relation_type={relation_type}, key={key}"
+ )
+
+ collection = self._get_relation_collection(relation_type)
+ logger.debug(
+ f"[CACHE] put_relation: collection={collection}, key={key}, "
+ f"relation_type={relation_type}"
+ )
+
+ try:
+ await self._await_in_bridge(
+ self._kv_store.put(key, value, collection=collection),
+ f"cache.put_relation.{relation_type}"
+ )
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to store relation: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"存储关系失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_relation(
+ self,
+ relation_type: str,
+ key: str
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 从关系层获取关系
+
+ Args:
+ relation_type: 关系类型
+ key: 关系的唯一标识
+
+ Returns:
+ 关系数据字典,如果不存在返回 None
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ collection = self._get_relation_collection(relation_type)
+ logger.debug(
+ f"[CACHE] get_relation: collection={collection}, key={key}, "
+ f"relation_type={relation_type}"
+ )
+
+ try:
+ result = await self._await_in_bridge(
+ self._kv_store.get(key, collection=collection),
+ f"cache.get_relation.{relation_type}"
+ )
+ return result
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to get relation: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"获取关系失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def delete_relation(self, relation_type: str, key: str) -> None:
+ """
+ 从关系层删除关系
+
+ Args:
+ relation_type: 关系类型
+ key: 关系的唯一标识
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ collection = self._get_relation_collection(relation_type)
+ logger.debug(
+ f"[CACHE] delete_relation: collection={collection}, key={key}, "
+ f"relation_type={relation_type}"
+ )
+
+ try:
+ await self._await_in_bridge(
+ self._kv_store.delete(key, collection=collection),
+ f"cache.delete_relation.{relation_type}"
+ )
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to delete relation: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"删除关系失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_all_relations_async(self, relation_type: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 异步获取指定类型的所有关系
+ """
+ collection = self._get_relation_collection(relation_type)
+ logger.debug(f"[CACHE] get_all_relations_async: collection={collection}, relation_type={relation_type}")
+ log_key = f"relation_scan:{relation_type}"
+ log_scan = self._should_log_scan(log_key)
+ async def _read():
+ relation_keys = await self._kv_store.keys(collection=collection)
+ if not relation_keys:
+ self._log_empty_collection(collection)
+ return {}
+
+ results = await self._kv_store.get_many(relation_keys, collection=collection)
+ relations: Dict[str, Dict[str, Any]] = {}
+ for i, key in enumerate(relation_keys):
+ if i < len(results) and results[i] is not None:
+ relations[key] = results[i]
+
+ if log_scan:
+ logger.debug(f"[CACHE] [GET] get_all_relations_async completed: found {len(relations)} relations")
+ return relations
+
+ try:
+ return await self._await_in_bridge(_read(), f"cache.get_all_relations_async.{relation_type}")
+ except Exception as e:
+ logger.error(f"[CACHE] [ERROR] Failed to get all relations asynchronously: relation_type={relation_type}, error={e}")
+ raise RuntimeError(f"Failed to get all relations asynchronously: relation_type={relation_type}, error={e}") from e
+
+ # ==================== 状态层操作 ====================
+
+ async def put_state(
+ self,
+ state_type: str,
+ key: str,
+ value: Dict[str, Any]
+ ) -> None:
+ """
+ 存储状态到状态层
+
+ Args:
+ state_type: 状态类型
+ key: 状态的唯一标识
+ value: 状态数据(必须是字典)
+
+ Raises:
+ ValueError: 如果 value 不是字典类型
+ RuntimeError: 如果 pykv 操作失败
+ """
+ if not isinstance(value, dict):
+ raise ValueError(
+ f"状态值必须是字典类型,实际类型: {type(value).__name__}. "
+ f"state_type={state_type}, key={key}"
+ )
+
+ collection = self._get_state_collection(state_type)
+ logger.debug(
+ f"[CACHE] put_state: collection={collection}, key={key}, "
+ f"state_type={state_type}"
+ )
+ try:
+ logger.info(f"[CACHE] [STATE] Storing state value: collection={collection}, key={key}, value={value}")
+ await self._await_in_bridge(
+ self._kv_store.put(key, value, collection=collection),
+ f"cache.put_state.{state_type}"
+ )
+ logger.info(f"[CACHE] [STATE] State stored successfully: collection={collection}, key={key}")
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to store state: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"存储状态失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_state(
+ self,
+ state_type: str,
+ key: str
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 从状态层获取状态
+
+ Args:
+ state_type: 状态类型
+ key: 状态的唯一标识
+
+ Returns:
+ 状态数据字典,如果不存在返回 None
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ collection = self._get_state_collection(state_type)
+ state_key = f"{state_type}:{key}"
+ log_key = f"state_read:{state_key}"
+ log_state = self._should_log_scan(log_key)
+ if log_state:
+ logger.debug(
+ f"[CACHE] get_state: collection={collection}, key={key}, "
+ f"state_type={state_type}"
+ )
+
+ try:
+ result = await self._await_in_bridge(
+ self._kv_store.get(key, collection=collection),
+ f"cache.get_state.{state_type}"
+ )
+ if log_state or self._has_state_changed(state_key, result):
+ logger.debug(f"[CACHE] [STATE] Reading state value: collection={collection}, key={key}, result={result}")
+ return result
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to get state: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"获取状态失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ async def get_all_states_async(self, state_type: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 异步获取指定类型的所有状态
+ """
+ collection = self._get_state_collection(state_type)
+ logger.debug(f"[CACHE] get_all_states_async: collection={collection}, state_type={state_type}")
+ log_key = f"state_scan:{state_type}"
+ log_scan = self._should_log_scan(log_key)
+ async def _read():
+ state_keys = await self._kv_store.keys(collection=collection)
+ if not state_keys:
+ self._log_empty_collection(collection)
+ return {}
+
+ results = await self._kv_store.get_many(state_keys, collection=collection)
+ states: Dict[str, Dict[str, Any]] = {}
+ for i, key in enumerate(state_keys):
+ if i < len(results) and results[i] is not None:
+ states[key] = results[i]
+
+ if log_scan:
+ logger.debug(f"[CACHE] [GET] get_all_states_async completed: found {len(states)} states")
+ return states
+
+ try:
+ return await self._await_in_bridge(_read(), f"cache.get_all_states_async.{state_type}")
+ except Exception as e:
+ logger.error(f"[CACHE] [ERROR] Failed to get all states asynchronously: state_type={state_type}, error={e}")
+ raise RuntimeError(f"Failed to get all states asynchronously: state_type={state_type}, error={e}") from e
+
+ async def delete_state(self, state_type: str, key: str) -> None:
+ """
+ 从状态层删除状态
+
+ Args:
+ state_type: 状态类型
+ key: 状态的唯一标识
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ collection = self._get_state_collection(state_type)
+ logger.debug(
+ f"[CACHE] delete_state: collection={collection}, key={key}, "
+ f"state_type={state_type}"
+ )
+
+ try:
+ await self._await_in_bridge(
+ self._kv_store.delete(key, collection=collection),
+ f"cache.delete_state.{state_type}"
+ )
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to delete state: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"删除状态失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ def put_state_sync(
+ self,
+ state_type: str,
+ key: str,
+ value: Dict[str, Any]
+ ) -> None:
+ """
+ 同步存储状态到状态层
+
+ 遵循核心原则:同步外壳在最外层使用一次 asyncio.run()
+
+ Args:
+ state_type: 状态类型
+ key: 状态的唯一标识
+ value: 状态数据(必须是字典)
+
+ Raises:
+ ValueError: 如果 value 不是字典类型
+ RuntimeError: 如果 pykv 操作失败
+ """
+ import asyncio
+
+ if not isinstance(value, dict):
+ raise ValueError(
+ f"状态值必须是字典类型,实际类型: {type(value).__name__}. "
+ f"state_type={state_type}, key={key}"
+ )
+
+ collection = self._get_state_collection(state_type)
+ logger.debug(
+ f"[CACHE] put_state_sync: collection={collection}, key={key}, "
+ f"state_type={state_type}"
+ )
+
+ async def _put_state_async():
+ """异步内部方法:只使用 await"""
+ await self._kv_store.put(key, value, collection=collection)
+
+ try:
+ if self._bridge:
+ self._bridge.run(_put_state_async(), op_name=f"cache.put_state_sync.{state_type}")
+ else:
+ asyncio.run(_put_state_async())
+
+ logger.info(f"[CACHE] [STATE] Synchronous state storage successful: collection={collection}, key={key}")
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to store state synchronously: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"同步存储状态失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ def get_state_sync(
+ self,
+ state_type: str,
+ key: str
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 同步从状态层获取状态
+
+ 遵循核心原则:同步外壳在最外层使用一次 asyncio.run()
+
+ Args:
+ state_type: 状态类型
+ key: 状态的唯一标识
+
+ Returns:
+ 状态数据字典,如果不存在返回 None
+
+ Raises:
+ RuntimeError: 如果 pykv 操作失败
+ """
+ import asyncio
+
+ collection = self._get_state_collection(state_type)
+ logger.debug(
+ f"[CACHE] get_state_sync: collection={collection}, key={key}, "
+ f"state_type={state_type}"
+ )
+
+ async def _get_state_async():
+ """异步内部方法:只使用 await"""
+ return await self._kv_store.get(key, collection=collection)
+
+ try:
+ if self._bridge:
+ result = self._bridge.run(_get_state_async(), op_name=f"cache.get_state_sync.{state_type}")
+ else:
+ result = asyncio.run(_get_state_async())
+
+ logger.debug(f"[CACHE] [GET] Synchronous state retrieval successful: collection={collection}, key={key}")
+ return result
+ except Exception as e:
+ logger.error(
+ f"[CACHE] [ERROR] Failed to get state synchronously: collection={collection}, key={key}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"同步获取状态失败: collection={collection}, key={key}, error={e}"
+ ) from e
+
+ # ==================== Agent 实体操作 ====================
+
+ async def create_agent(
+ self,
+ agent_id: str,
+ created_time: int,
+ is_global: bool = False
+ ) -> None:
+ """
+ 创建 Agent 实体
+
+ Args:
+ agent_id: Agent ID
+ created_time: 创建时间戳
+ is_global: 是否为全局代理
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果创建失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ from .models import AgentEntity
+
+ # 检查 Agent 是否已存在
+ existing = await self.get_entity("agents", agent_id)
+ if existing:
+ raise ValueError(f"Agent already exists: agent_id={agent_id}")
+
+ # 创建 Agent 实体
+ entity = AgentEntity(
+ agent_id=agent_id,
+ created_time=created_time,
+ last_active=created_time,
+ is_global=is_global
+ )
+
+ # 存储到实体层
+ await self.put_entity("agents", agent_id, entity.to_dict())
+
+ logger.info(
+ f"[CACHE] [AGENT] Created Agent entity: agent_id={agent_id}, "
+ f"is_global={is_global}"
+ )
+
+ async def get_agent(self, agent_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取 Agent 实体
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ Agent 实体数据,如果不存在返回 None
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ # 从实体层获取
+ data = await self.get_entity("agents", agent_id)
+
+ if data is None:
+ logger.debug(f"[CACHE] [AGENT] Agent does not exist: agent_id={agent_id}")
+ return None
+
+ logger.debug(f"[CACHE] [AGENT] Getting Agent entity: agent_id={agent_id}")
+ return data
+
+ async def update_agent_last_active(
+ self,
+ agent_id: str,
+ last_active: int
+ ) -> None:
+ """
+ 更新 Agent 最后活跃时间
+
+ Args:
+ agent_id: Agent ID
+ last_active: 最后活跃时间戳
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果 Agent 不存在
+ RuntimeError: 如果更新失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ # 获取现有 Agent
+ data = await self.get_agent(agent_id)
+ if data is None:
+ raise KeyError(f"Agent does not exist: agent_id={agent_id}")
+
+ # 更新最后活跃时间
+ data["last_active"] = last_active
+
+ # 保存到实体层
+ await self.put_entity("agents", agent_id, data)
+
+ logger.debug(
+ f"[CACHE] [AGENT] Updating Agent last active time: agent_id={agent_id}, "
+ f"last_active={last_active}"
+ )
+
+ # ==================== Store 配置操作 ====================
+
+ async def set_store_config(self, config: Dict[str, Any]) -> None:
+ """
+ 设置 Store 配置
+
+ Args:
+ config: Store 配置数据
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果设置失败
+ """
+ if not isinstance(config, dict):
+ raise ValueError(
+ f"Store 配置必须是字典类型,实际类型: {type(config).__name__}"
+ )
+
+ from .models import StoreConfig
+
+ # 验证配置数据
+ try:
+ StoreConfig.from_dict(config)
+ except Exception as e:
+ raise ValueError(f"Invalid Store configuration: {e}") from e
+
+ # 存储到实体层,使用固定的 key "mcpstore"
+ await self.put_entity("store", "mcpstore", config)
+
+ logger.info("[CACHE] [STORE] Setting Store configuration")
+
+ async def get_store_config(self) -> Optional[Dict[str, Any]]:
+ """
+ 获取 Store 配置
+
+ Returns:
+ Store 配置数据,如果不存在返回 None
+
+ Raises:
+ RuntimeError: 如果获取失败
+ """
+ # 从实体层获取,使用固定的 key "mcpstore"
+ data = await self.get_entity("store", "mcpstore")
+
+ if data is None:
+ logger.debug("[CACHE] [STORE] Store configuration does not exist")
+ return None
+
+ logger.debug("[CACHE] [STORE] Getting Store configuration")
+ return data
diff --git a/src/mcpstore/core/cache/models.py b/src/mcpstore/core/cache/models.py
new file mode 100644
index 00000000..ac70eec3
--- /dev/null
+++ b/src/mcpstore/core/cache/models.py
@@ -0,0 +1,473 @@
+"""
+缓存架构数据模型
+
+定义三层缓存架构中使用的所有数据模型。
+"""
+
+from dataclasses import dataclass, field, asdict
+from typing import Any, Dict, List, Optional
+
+
+# ==================== 实体层数据模型 ====================
+
+
+@dataclass
+class ServiceEntity:
+ """
+ 服务实体
+
+ 存储在实体层的服务配置和元数据。
+ """
+ service_global_name: str
+ service_original_name: str
+ source_agent: str
+ config: Dict[str, Any]
+ added_time: int
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ServiceEntity':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "service_global_name",
+ "service_original_name",
+ "source_agent",
+ "config",
+ "added_time"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ service_global_name=data["service_global_name"],
+ service_original_name=data["service_original_name"],
+ source_agent=data["source_agent"],
+ config=data["config"],
+ added_time=data["added_time"]
+ )
+
+
+@dataclass
+class ToolEntity:
+ """
+ 工具实体
+
+ 存储在实体层的工具定义和 schema。
+ """
+ tool_global_name: str
+ tool_original_name: str
+ service_global_name: str
+ service_original_name: str
+ source_agent: str
+ description: str
+ input_schema: Dict[str, Any]
+ created_time: int
+ tool_hash: str
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ToolEntity':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "tool_global_name",
+ "tool_original_name",
+ "service_global_name",
+ "service_original_name",
+ "source_agent",
+ "description",
+ "input_schema",
+ "created_time",
+ "tool_hash"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ tool_global_name=data["tool_global_name"],
+ tool_original_name=data["tool_original_name"],
+ service_global_name=data["service_global_name"],
+ service_original_name=data["service_original_name"],
+ source_agent=data["source_agent"],
+ description=data["description"],
+ input_schema=data["input_schema"],
+ created_time=data["created_time"],
+ tool_hash=data["tool_hash"]
+ )
+
+
+@dataclass
+class AgentEntity:
+ """
+ Agent 实体
+
+ 存储在实体层的 Agent 基础信息。
+ """
+ agent_id: str
+ created_time: int
+ last_active: int
+ is_global: bool = False
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'AgentEntity':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = ["agent_id", "created_time", "last_active"]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ agent_id=data["agent_id"],
+ created_time=data["created_time"],
+ last_active=data["last_active"],
+ is_global=data.get("is_global", False)
+ )
+
+
+@dataclass
+class StoreConfig:
+ """
+ Store 配置
+
+ 存储在实体层的全局配置信息。
+ """
+ mcp_version: str
+ setup_time: int
+ config_version: str
+ mcp_json_path: str
+ static_main_agent: str = "global_agent_store"
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'StoreConfig':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "mcp_version",
+ "setup_time",
+ "config_version",
+ "mcp_json_path"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ mcp_version=data["mcp_version"],
+ setup_time=data["setup_time"],
+ config_version=data["config_version"],
+ mcp_json_path=data["mcp_json_path"],
+ static_main_agent=data.get("static_main_agent", "global_agent_store")
+ )
+
+
+# ==================== 关系层数据模型 ====================
+
+
+@dataclass
+class ServiceRelationItem:
+ """
+ 服务关系项
+
+ Agent-Service 关系中的单个服务项。
+ """
+ service_original_name: str
+ service_global_name: str
+ client_id: str
+ established_time: int
+ last_access: Optional[int] = None
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ServiceRelationItem':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "service_original_name",
+ "service_global_name",
+ "client_id",
+ "established_time"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ service_original_name=data["service_original_name"],
+ service_global_name=data["service_global_name"],
+ client_id=data["client_id"],
+ established_time=data["established_time"],
+ last_access=data.get("last_access")
+ )
+
+
+@dataclass
+class AgentServiceRelation:
+ """
+ Agent-Service 关系
+
+ 存储在关系层的 Agent 与服务的映射关系。
+ """
+ services: List[ServiceRelationItem] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "services": [item.to_dict() for item in self.services]
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'AgentServiceRelation':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ services_data = data.get("services", [])
+ if not isinstance(services_data, list):
+ raise ValueError(f"services must be a list type, actual type: {type(services_data).__name__}")
+
+ services = [
+ ServiceRelationItem.from_dict(item)
+ for item in services_data
+ ]
+
+ return cls(services=services)
+
+
+@dataclass
+class ToolRelationItem:
+ """
+ 工具关系项
+
+ Service-Tool 关系中的单个工具项。
+ """
+ tool_global_name: str
+ tool_original_name: str
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ToolRelationItem':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = ["tool_global_name", "tool_original_name"]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ return cls(
+ tool_global_name=data["tool_global_name"],
+ tool_original_name=data["tool_original_name"]
+ )
+
+
+@dataclass
+class ServiceToolRelation:
+ """
+ Service-Tool 关系
+
+ 存储在关系层的服务与工具的映射关系。
+ """
+ service_global_name: str
+ service_original_name: str
+ source_agent: str
+ tools: List[ToolRelationItem] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "service_global_name": self.service_global_name,
+ "service_original_name": self.service_original_name,
+ "source_agent": self.source_agent,
+ "tools": [item.to_dict() for item in self.tools]
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ServiceToolRelation':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "service_global_name",
+ "service_original_name",
+ "source_agent"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ tools_data = data.get("tools", [])
+ if not isinstance(tools_data, list):
+ raise ValueError(f"tools must be a list type, actual type: {type(tools_data).__name__}")
+
+ tools = [
+ ToolRelationItem.from_dict(item)
+ for item in tools_data
+ ]
+
+ return cls(
+ service_global_name=data["service_global_name"],
+ service_original_name=data["service_original_name"],
+ source_agent=data["source_agent"],
+ tools=tools
+ )
+
+
+# ==================== 状态层数据模型 ====================
+
+
+@dataclass
+class ToolStatusItem:
+ """
+ 工具状态项
+
+ 服务状态中的单个工具状态。
+ """
+ tool_global_name: str
+ tool_original_name: str
+ status: str # "available" | "unavailable"
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ToolStatusItem':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = ["tool_global_name", "tool_original_name", "status"]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ # 验证状态值
+ valid_statuses = ["available", "unavailable"]
+ if data["status"] not in valid_statuses:
+ raise ValueError(
+ f"无效的工具状态: {data['status']}. "
+ f"有效值: {valid_statuses}"
+ )
+
+ return cls(
+ tool_global_name=data["tool_global_name"],
+ tool_original_name=data["tool_original_name"],
+ status=data["status"]
+ )
+
+
+@dataclass
+class ServiceStatus:
+ """
+ 服务状态
+
+ 存储在状态层的服务运行时状态。
+ """
+ service_global_name: str
+ health_status: str # "healthy" | "unhealthy" | "unknown"
+ last_health_check: int
+ connection_attempts: int
+ max_connection_attempts: int
+ current_error: Optional[str]
+ tools: List[ToolStatusItem] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "service_global_name": self.service_global_name,
+ "health_status": self.health_status,
+ "last_health_check": self.last_health_check,
+ "connection_attempts": self.connection_attempts,
+ "max_connection_attempts": self.max_connection_attempts,
+ "current_error": self.current_error,
+ "tools": [item.to_dict() for item in self.tools]
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ServiceStatus':
+ """从字典创建"""
+ if not isinstance(data, dict):
+ raise ValueError(f"Data must be a dictionary type, actual type: {type(data).__name__}")
+
+ required_fields = [
+ "service_global_name",
+ "health_status",
+ "last_health_check",
+ "connection_attempts",
+ "max_connection_attempts"
+ ]
+
+ for field_name in required_fields:
+ if field_name not in data:
+ raise ValueError(f"Missing required field: {field_name}")
+
+ # 验证健康状态值
+ valid_health_statuses = ["healthy", "unhealthy", "unknown", "initializing", "warning", "disconnected", "reconnecting"]
+ if data["health_status"] not in valid_health_statuses:
+ raise ValueError(
+ f"无效的健康状态: {data['health_status']}. "
+ f"有效值: {valid_health_statuses}"
+ )
+
+ tools_data = data.get("tools", [])
+ if not isinstance(tools_data, list):
+ raise ValueError(f"tools must be a list type, actual type: {type(tools_data).__name__}")
+
+ tools = [
+ ToolStatusItem.from_dict(item)
+ for item in tools_data
+ ]
+
+ return cls(
+ service_global_name=data["service_global_name"],
+ health_status=data["health_status"],
+ last_health_check=data["last_health_check"],
+ connection_attempts=data["connection_attempts"],
+ max_connection_attempts=data["max_connection_attempts"],
+ current_error=data.get("current_error"),
+ tools=tools
+ )
diff --git a/src/mcpstore/core/cache/naming_service.py b/src/mcpstore/core/cache/naming_service.py
new file mode 100644
index 00000000..411159eb
--- /dev/null
+++ b/src/mcpstore/core/cache/naming_service.py
@@ -0,0 +1,183 @@
+"""
+命名服务
+
+负责处理服务和工具的命名转换,实现双重视角命名:
+- Agent 视角:看到原始名称(如 "context7")
+- Store 视角:看到全局唯一名称(如 "context7_byagent_agent1")
+"""
+
+import logging
+from typing import Tuple
+
+logger = logging.getLogger(__name__)
+
+
+class NamingService:
+ """
+ 命名服务
+
+ 提供服务和工具的全局命名生成和解析功能。
+ """
+
+ # 全局代理标识,用于 Store 视角的服务管理
+ GLOBAL_AGENT_STORE = "global_agent_store"
+
+ # 命名分隔符
+ AGENT_SEPARATOR = "_byagent_"
+
+ @staticmethod
+ def generate_service_global_name(original_name: str, agent_id: str) -> str:
+ """
+ 生成服务全局名称
+
+ 规则:
+ - 如果 agent_id 是 "global_agent_store",返回原始名称
+ - 否则,返回 "{original_name}_byagent_{agent_id}"
+
+ Args:
+ original_name: 服务原始名称
+ agent_id: Agent ID
+
+ Returns:
+ 服务全局名称
+
+ Examples:
+ >>> NamingService.generate_service_global_name("context7", "agent1")
+ "context7_byagent_agent1"
+
+ >>> NamingService.generate_service_global_name("context7", "global_agent_store")
+ "context7"
+ """
+ if not original_name:
+ raise ValueError("Service original name cannot be empty")
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ if agent_id == NamingService.GLOBAL_AGENT_STORE:
+ global_name = original_name
+ else:
+ global_name = f"{original_name}{NamingService.AGENT_SEPARATOR}{agent_id}"
+
+ logger.debug(
+ f"[NAMING] Generated service global name: original_name={original_name}, "
+ f"agent_id={agent_id}, global_name={global_name}"
+ )
+
+ return global_name
+
+ @staticmethod
+ def generate_tool_global_name(
+ service_global_name: str,
+ tool_original_name: str
+ ) -> str:
+ """
+ 生成工具全局名称
+
+ 规则:
+ - 如果工具名已经以服务全局名称开头,直接返回工具名
+ - 否则,格式为 "{service_global_name}_{tool_original_name}"
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_original_name: 工具原始名称
+
+ Returns:
+ 工具全局名称
+
+ Examples:
+ >>> NamingService.generate_tool_global_name(
+ ... "context7_byagent_agent1",
+ ... "resolve-library-id"
+ ... )
+ "context7_byagent_agent1_resolve-library-id"
+
+ >>> NamingService.generate_tool_global_name(
+ ... "context7",
+ ... "resolve-library-id"
+ ... )
+ "context7_resolve-library-id"
+
+ >>> NamingService.generate_tool_global_name(
+ ... "mcpstore",
+ ... "mcpstore_get_current_weather"
+ ... )
+ "mcpstore_get_current_weather" # 已包含服务前缀,不重复添加
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not tool_original_name:
+ raise ValueError("Tool original name cannot be empty")
+
+ # 检查工具名是否已经以服务全局名称开头
+ # 避免重复添加前缀
+ if tool_original_name.startswith(f"{service_global_name}_"):
+ tool_global_name = tool_original_name
+ else:
+ tool_global_name = f"{service_global_name}_{tool_original_name}"
+
+ logger.debug(
+ f"[NAMING] Generated tool global name: service_global_name={service_global_name}, "
+ f"tool_original_name={tool_original_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ return tool_global_name
+
+ @staticmethod
+ def parse_service_global_name(global_name: str) -> Tuple[str, str]:
+ """
+ 解析服务全局名称
+
+ 规则:
+ - 如果包含 "_byagent_",拆分为 (original_name, agent_id)
+ - 否则,认为是 global_agent_store 的服务,返回 (global_name, "global_agent_store")
+
+ Args:
+ global_name: 服务全局名称
+
+ Returns:
+ (original_name, agent_id) 元组
+
+ Examples:
+ >>> NamingService.parse_service_global_name("context7_byagent_agent1")
+ ("context7", "agent1")
+
+ >>> NamingService.parse_service_global_name("context7")
+ ("context7", "global_agent_store")
+ """
+ if not global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ if NamingService.AGENT_SEPARATOR not in global_name:
+ # 没有分隔符,认为是 global_agent_store 的服务
+ original_name = global_name
+ agent_id = NamingService.GLOBAL_AGENT_STORE
+ else:
+ # 从右侧拆分,只拆分一次(防止服务名中包含分隔符)
+ parts = global_name.rsplit(NamingService.AGENT_SEPARATOR, 1)
+ if len(parts) != 2:
+ raise ValueError(
+ f"Invalid service global name format: {global_name}. "
+ f"Expected format: 'name{NamingService.AGENT_SEPARATOR}agent_id' or 'name'"
+ )
+ original_name, agent_id = parts
+
+ logger.debug(
+ f"[NAMING] Parsed service global name: global_name={global_name}, "
+ f"original_name={original_name}, agent_id={agent_id}"
+ )
+
+ return original_name, agent_id
+
+ @staticmethod
+ def is_global_agent_service(agent_id: str) -> bool:
+ """
+ 判断是否为全局代理的服务
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 如果是 global_agent_store,返回 True
+ """
+ return agent_id == NamingService.GLOBAL_AGENT_STORE
diff --git a/src/mcpstore/core/cache/relationship_manager.py b/src/mcpstore/core/cache/relationship_manager.py
new file mode 100644
index 00000000..fb946585
--- /dev/null
+++ b/src/mcpstore/core/cache/relationship_manager.py
@@ -0,0 +1,573 @@
+"""
+关系管理器
+
+负责管理实体间的关系映射:
+- Agent-Service 关系
+- Service-Tool 关系
+"""
+
+import logging
+import time
+from typing import Any, Dict, List, TYPE_CHECKING
+
+from .models import (
+ AgentServiceRelation,
+ ServiceRelationItem,
+ ServiceToolRelation,
+ ToolRelationItem
+)
+
+if TYPE_CHECKING:
+ from .cache_layer_manager import CacheLayerManager
+
+logger = logging.getLogger(__name__)
+
+
+class RelationshipManager:
+ """
+ 关系管理器
+
+ 管理实体间的关系映射,包括:
+ - Agent-Service 关系(key 是 agent_id)
+ - Service-Tool 关系(key 是 service_global_name)
+ """
+
+ def __init__(self, cache_layer: 'CacheLayerManager'):
+ """
+ 初始化关系管理器
+
+ Args:
+ cache_layer: 缓存层管理器实例
+ """
+ self._cache_layer = cache_layer
+ logger.debug("[RELATIONSHIP] Initializing RelationshipManager")
+
+ # ==================== Agent-Service 关系管理 ====================
+
+ async def add_agent_service(
+ self,
+ agent_id: str,
+ service_original_name: str,
+ service_global_name: str,
+ client_id: str
+ ) -> None:
+ """
+ 添加 Agent-Service 关系
+
+ Args:
+ agent_id: Agent ID
+ service_original_name: 服务原始名称
+ service_global_name: 服务全局名称
+ client_id: 客户端 ID
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果服务实体不存在
+ RuntimeError: 如果添加失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not service_original_name:
+ raise ValueError("Service original name cannot be empty")
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not client_id:
+ raise ValueError("Client ID cannot be empty")
+
+ # 验证服务实体存在
+ service_entity = await self._cache_layer.get_entity(
+ "services",
+ service_global_name
+ )
+ if service_entity is None:
+ raise KeyError(
+ f"Service entity does not exist: service_global_name={service_global_name}"
+ )
+
+ logger.debug(
+ f"[RELATIONSHIP] Adding Agent-Service relation: agent_id={agent_id}, "
+ f"service_original_name={service_original_name}, "
+ f"service_global_name={service_global_name}, client_id={client_id}"
+ )
+
+ # 获取现有关系
+ relation_data = await self._cache_layer.get_relation(
+ "agent_services",
+ agent_id
+ )
+
+ if relation_data is None:
+ # 创建新关系
+ relation = AgentServiceRelation(services=[])
+ else:
+ # 解析现有关系
+ relation = AgentServiceRelation.from_dict(relation_data)
+
+ # 检查服务是否已存在(基于全局名称判断)
+ for i, service in enumerate(relation.services):
+ if service.service_global_name == service_global_name:
+ # 全局名称相同,认为是同一个关系,更新配置
+ relation.services[i] = ServiceRelationItem(
+ service_original_name=service_original_name,
+ service_global_name=service_global_name,
+ client_id=client_id,
+ established_time=service.established_time,
+ last_access=int(time.time())
+ )
+
+ await self._cache_layer.put_relation(
+ "agent_services",
+ agent_id,
+ relation.to_dict()
+ )
+
+ logger.info(
+ f"[RELATIONSHIP] Updated Agent-Service relation: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}"
+ )
+ return
+
+ # 添加新服务
+ current_time = int(time.time())
+ new_service = ServiceRelationItem(
+ service_original_name=service_original_name,
+ service_global_name=service_global_name,
+ client_id=client_id,
+ established_time=current_time,
+ last_access=current_time
+ )
+ relation.services.append(new_service)
+
+ # 保存关系
+ await self._cache_layer.put_relation(
+ "agent_services",
+ agent_id,
+ relation.to_dict()
+ )
+
+ logger.info(
+ f"[RELATIONSHIP] Successfully added Agent-Service relation: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ async def remove_agent_service(
+ self,
+ agent_id: str,
+ service_global_name: str
+ ) -> None:
+ """
+ 移除 Agent-Service 关系
+
+ Args:
+ agent_id: Agent ID
+ service_global_name: 服务全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果关系不存在
+ RuntimeError: 如果移除失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ logger.debug(
+ f"[RELATIONSHIP] Removing Agent-Service relation: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ # 获取现有关系
+ relation_data = await self._cache_layer.get_relation(
+ "agent_services",
+ agent_id
+ )
+
+ if relation_data is None:
+ raise KeyError(
+ f"Agent relation does not exist: agent_id={agent_id}"
+ )
+
+ # 解析关系
+ relation = AgentServiceRelation.from_dict(relation_data)
+
+ # 查找并移除服务
+ original_count = len(relation.services)
+ relation.services = [
+ service for service in relation.services
+ if service.service_global_name != service_global_name
+ ]
+
+ if len(relation.services) == original_count:
+ raise KeyError(
+ f"Service does not exist in Agent relation: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ # 保存更新后的关系
+ if len(relation.services) == 0:
+ # 如果没有服务了,删除整个关系
+ await self._cache_layer.delete_relation("agent_services", agent_id)
+ logger.info(
+ f"[RELATIONSHIP] Deleted empty Agent relation: agent_id={agent_id}"
+ )
+ else:
+ # 保存更新后的关系
+ await self._cache_layer.put_relation(
+ "agent_services",
+ agent_id,
+ relation.to_dict()
+ )
+ logger.info(
+ f"[RELATIONSHIP] Successfully removed Agent-Service relation: "
+ f"agent_id={agent_id}, service_global_name={service_global_name}"
+ )
+
+ async def get_agent_services(
+ self,
+ agent_id: str
+ ) -> List[Dict[str, Any]]:
+ """
+ 获取 Agent 的所有服务关系
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务关系列表,如果不存在返回空列表
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ logger.debug(
+ f"[RELATIONSHIP] Getting Agent service relations: agent_id={agent_id}"
+ )
+
+ # 获取关系
+ relation_data = await self._cache_layer.get_relation(
+ "agent_services",
+ agent_id
+ )
+
+ if relation_data is None:
+ logger.debug(
+ f"[RELATIONSHIP] Agent relation does not exist: agent_id={agent_id}"
+ )
+ return []
+
+ # 解析关系
+ relation = AgentServiceRelation.from_dict(relation_data)
+
+ # 转换为字典列表
+ services = [service.to_dict() for service in relation.services]
+
+ logger.debug(
+ f"[RELATIONSHIP] Retrieved {len(services)} service relations: "
+ f"agent_id={agent_id}"
+ )
+
+ return services
+
+ # ==================== Service-Tool 关系管理 ====================
+
+ async def add_service_tool(
+ self,
+ service_global_name: str,
+ service_original_name: str,
+ source_agent: str,
+ tool_global_name: str,
+ tool_original_name: str
+ ) -> None:
+ """
+ 添加 Service-Tool 关系
+
+ Args:
+ service_global_name: 服务全局名称
+ service_original_name: 服务原始名称
+ source_agent: 来源 Agent
+ tool_global_name: 工具全局名称
+ tool_original_name: 工具原始名称
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果工具实体不存在
+ RuntimeError: 如果添加失败
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not service_original_name:
+ raise ValueError("Service original name cannot be empty")
+ if not source_agent:
+ raise ValueError("Source Agent cannot be empty")
+ if not tool_global_name:
+ raise ValueError("Tool global name cannot be empty")
+ if not tool_original_name:
+ raise ValueError("Tool original name cannot be empty")
+
+ # 注意:不在这里验证工具实体存在性
+ # 原因:
+ # 1. 在 cache_manager._create_tool_entities_and_relations 中,
+ # create_tool 和 add_service_tool 是顺序调用的
+ # 2. Redis 写入后可能存在短暂的读取延迟
+ # 3. 关系层和实体层是独立的,关系层不应该依赖实体层的即时可读性
+ # 4. 调用方负责确保工具实体已创建
+
+ logger.debug(
+ f"[RELATIONSHIP] Adding Service-Tool relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ # 获取现有关系
+ relation_data = await self._cache_layer.get_relation(
+ "service_tools",
+ service_global_name
+ )
+
+ if relation_data is None:
+ # 创建新关系
+ relation = ServiceToolRelation(
+ service_global_name=service_global_name,
+ service_original_name=service_original_name,
+ source_agent=source_agent,
+ tools=[]
+ )
+ else:
+ # 解析现有关系
+ relation = ServiceToolRelation.from_dict(relation_data)
+
+ # 检查工具是否已存在(基于全局名称判断)
+ for i, tool in enumerate(relation.tools):
+ if tool.tool_global_name == tool_global_name:
+ # 全局名称相同,认为是同一个关系,更新配置
+ relation.tools[i] = ToolRelationItem(
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_original_name
+ )
+
+ await self._cache_layer.put_relation(
+ "service_tools",
+ service_global_name,
+ relation.to_dict()
+ )
+
+ logger.info(
+ f"[RELATIONSHIP] Updated Service-Tool relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+ return
+
+ # 添加新工具
+ new_tool = ToolRelationItem(
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_original_name
+ )
+ relation.tools.append(new_tool)
+
+ # 保存关系
+ await self._cache_layer.put_relation(
+ "service_tools",
+ service_global_name,
+ relation.to_dict()
+ )
+
+ logger.info(
+ f"[RELATIONSHIP] Successfully added Service-Tool relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ async def remove_service_tool(
+ self,
+ service_global_name: str,
+ tool_global_name: str
+ ) -> None:
+ """
+ 移除 Service-Tool 关系
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_global_name: 工具全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果关系不存在
+ RuntimeError: 如果移除失败
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not tool_global_name:
+ raise ValueError("Tool global name cannot be empty")
+
+ logger.debug(
+ f"[RELATIONSHIP] Removing Service-Tool relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ # 获取现有关系
+ relation_data = await self._cache_layer.get_relation(
+ "service_tools",
+ service_global_name
+ )
+
+ if relation_data is None:
+ raise KeyError(
+ f"Service relation does not exist: service_global_name={service_global_name}"
+ )
+
+ # 解析关系
+ relation = ServiceToolRelation.from_dict(relation_data)
+
+ # 查找并移除工具
+ original_count = len(relation.tools)
+ relation.tools = [
+ tool for tool in relation.tools
+ if tool.tool_global_name != tool_global_name
+ ]
+
+ if len(relation.tools) == original_count:
+ raise KeyError(
+ f"Tool does not exist in service relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ # 保存更新后的关系
+ if len(relation.tools) == 0:
+ # 如果没有工具了,删除整个关系
+ await self._cache_layer.delete_relation(
+ "service_tools",
+ service_global_name
+ )
+ logger.info(
+ f"[RELATIONSHIP] Deleted empty service relation: "
+ f"service_global_name={service_global_name}"
+ )
+ else:
+ # 保存更新后的关系
+ await self._cache_layer.put_relation(
+ "service_tools",
+ service_global_name,
+ relation.to_dict()
+ )
+ logger.info(
+ f"[RELATIONSHIP] Successfully removed Service-Tool relation: "
+ f"service_global_name={service_global_name}, "
+ f"tool_global_name={tool_global_name}"
+ )
+
+ async def get_service_tools(
+ self,
+ service_global_name: str
+ ) -> List[Dict[str, Any]]:
+ """
+ 获取服务的所有工具关系
+
+ Args:
+ service_global_name: 服务全局名称
+
+ Returns:
+ 工具关系列表,如果不存在返回空列表
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ logger.debug(
+ f"[RELATIONSHIP] Getting service tool relations: "
+ f"service_global_name={service_global_name}"
+ )
+
+ # 获取关系
+ relation_data = await self._cache_layer.get_relation(
+ "service_tools",
+ service_global_name
+ )
+
+ if relation_data is None:
+ logger.debug(
+ f"[RELATIONSHIP] Service relation does not exist: "
+ f"service_global_name={service_global_name}"
+ )
+ return []
+
+ # 解析关系
+ relation = ServiceToolRelation.from_dict(relation_data)
+
+ # 转换为字典列表
+ tools = [tool.to_dict() for tool in relation.tools]
+
+ logger.debug(
+ f"[RELATIONSHIP] Retrieved {len(tools)} tool relations: "
+ f"service_global_name={service_global_name}"
+ )
+
+ return tools
+
+ # ==================== 级联删除操作 ====================
+
+ async def remove_service_cascade(
+ self,
+ agent_id: str,
+ service_global_name: str
+ ) -> None:
+ """
+ 级联删除服务相关的所有关系
+
+ 删除顺序:
+ 1. 移除 Agent-Service 关系
+ 2. 删除 Service-Tool 关系
+
+ Args:
+ agent_id: Agent ID
+ service_global_name: 服务全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果删除失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ logger.info(
+ f"[RELATIONSHIP] Cascading delete service relations: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ # 1. 移除 Agent-Service 关系
+ try:
+ await self.remove_agent_service(agent_id, service_global_name)
+ except KeyError as e:
+ logger.warning(
+ f"[RELATIONSHIP] Agent-Service relation does not exist, skipping: {e}"
+ )
+
+ # 2. 删除 Service-Tool 关系
+ try:
+ await self._cache_layer.delete_relation(
+ "service_tools",
+ service_global_name
+ )
+ logger.info(
+ f"[RELATIONSHIP] Deleted Service-Tool relation: "
+ f"service_global_name={service_global_name}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"[RELATIONSHIP] Failed to delete Service-Tool relation: {e}"
+ )
+
+ logger.info(
+ f"[RELATIONSHIP] Cascading delete completed: service_global_name={service_global_name}"
+ )
diff --git a/src/mcpstore/core/cache/service_entity_manager.py b/src/mcpstore/core/cache/service_entity_manager.py
new file mode 100644
index 00000000..a28fee76
--- /dev/null
+++ b/src/mcpstore/core/cache/service_entity_manager.py
@@ -0,0 +1,344 @@
+"""
+服务实体管理器
+
+负责管理服务实体的 CRUD 操作。
+"""
+
+import logging
+import time
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
+
+from .models import ServiceEntity
+from .naming_service import NamingService
+
+if TYPE_CHECKING:
+ from .cache_layer_manager import CacheLayerManager
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceEntityManager:
+ """
+ 服务实体管理器
+
+ 管理服务实体的创建、查询、更新和删除操作。
+ """
+
+ def __init__(
+ self,
+ cache_layer: 'CacheLayerManager',
+ naming: NamingService
+ ):
+ """
+ 初始化服务实体管理器
+
+ Args:
+ cache_layer: 缓存层管理器
+ naming: 命名服务
+ """
+ self._cache_layer = cache_layer
+ self._naming = naming
+ logger.debug("[SERVICE_ENTITY] Initializing ServiceEntityManager")
+
+ async def create_service(
+ self,
+ agent_id: str,
+ original_name: str,
+ config: Dict[str, Any]
+ ) -> str:
+ """
+ 创建服务实体
+
+ Args:
+ agent_id: Agent ID
+ original_name: 服务原始名称
+ config: 服务配置
+
+ Returns:
+ 服务全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果创建失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not original_name:
+ raise ValueError("Service original name cannot be empty")
+ if not isinstance(config, dict):
+ raise ValueError(
+ f"Service config must be a dictionary type, actual type: {type(config).__name__}"
+ )
+
+ # 生成全局名称
+ global_name = self._naming.generate_service_global_name(
+ original_name,
+ agent_id
+ )
+
+ # 检查服务是否已存在(基于全局名称判断)
+ existing = await self._cache_layer.get_entity("services", global_name)
+ if existing:
+ # 全局名称相同,认为是同一个实体,更新配置
+ entity = ServiceEntity(
+ service_global_name=global_name,
+ service_original_name=original_name,
+ source_agent=agent_id,
+ config=config,
+ added_time=existing.get("added_time", int(time.time()))
+ )
+
+ await self._cache_layer.put_entity(
+ "services",
+ global_name,
+ entity.to_dict()
+ )
+
+ logger.info(
+ f"[SERVICE_ENTITY] Updated service entity: global_name={global_name}, "
+ f"original_name={original_name}, agent_id={agent_id}"
+ )
+ return global_name
+
+ # 创建新服务实体
+ entity = ServiceEntity(
+ service_global_name=global_name,
+ service_original_name=original_name,
+ source_agent=agent_id,
+ config=config,
+ added_time=int(time.time())
+ )
+
+ # 存储到实体层
+ await self._cache_layer.put_entity(
+ "services",
+ global_name,
+ entity.to_dict()
+ )
+
+ logger.info(
+ f"[SERVICE_ENTITY] Created service entity: global_name={global_name}, "
+ f"original_name={original_name}, agent_id={agent_id}"
+ )
+
+ return global_name
+
+ async def get_service(self, global_name: str) -> Optional[ServiceEntity]:
+ """
+ 获取服务实体
+
+ Args:
+ global_name: 服务全局名称
+
+ Returns:
+ 服务实体,如果不存在返回 None
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ # 从实体层获取
+ data = await self._cache_layer.get_entity("services", global_name)
+
+ if data is None:
+ logger.debug(
+ f"[SERVICE_ENTITY] Service does not exist: global_name={global_name}"
+ )
+ return None
+
+ # 转换为实体对象
+ try:
+ entity = ServiceEntity.from_dict(data)
+ logger.debug(
+ f"[SERVICE_ENTITY] Retrieved service entity: global_name={global_name}"
+ )
+ return entity
+ except Exception as e:
+ logger.error(
+ f"[SERVICE_ENTITY] Failed to parse service entity: global_name={global_name}, "
+ f"error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to parse service entity: global_name={global_name}, error={e}"
+ ) from e
+
+ async def update_service(
+ self,
+ global_name: str,
+ config: Dict[str, Any]
+ ) -> None:
+ """
+ 更新服务配置
+
+ Args:
+ global_name: 服务全局名称
+ config: 新的服务配置
+
+ Raises:
+ ValueError: 如果参数无效
+ KeyError: 如果服务不存在
+ RuntimeError: 如果更新失败
+ """
+ if not global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not isinstance(config, dict):
+ raise ValueError(
+ f"Service config must be a dictionary type, actual type: {type(config).__name__}"
+ )
+
+ # 获取现有服务
+ entity = await self.get_service(global_name)
+ if entity is None:
+ raise KeyError(f"Service does not exist: global_name={global_name}")
+
+ # 更新配置
+ entity.config = config
+
+ # 保存到实体层
+ await self._cache_layer.put_entity(
+ "services",
+ global_name,
+ entity.to_dict()
+ )
+
+ logger.info(
+ f"[SERVICE_ENTITY] Updated service config: global_name={global_name}"
+ )
+
+ async def delete_service(self, global_name: str) -> None:
+ """
+ 删除服务实体
+
+ Args:
+ global_name: 服务全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果删除失败
+ """
+ if not global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ # 从实体层删除
+ await self._cache_layer.delete_entity("services", global_name)
+
+ logger.info(
+ f"[SERVICE_ENTITY] Deleted service entity: global_name={global_name}"
+ )
+
+ async def list_services_by_agent(
+ self,
+ agent_id: str
+ ) -> List[ServiceEntity]:
+ """
+ 列出 Agent 的所有服务
+
+ 注意:此方法需要配合 RelationshipManager 使用,
+ 先从关系层获取服务列表,再批量获取实体。
+
+ 这里提供一个简化版本,仅用于测试。
+ 实际使用时应该通过 RelationshipManager 获取服务列表。
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务实体列表
+
+ Raises:
+ ValueError: 如果参数无效
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ # 注意:这是一个简化实现
+ # 实际应该从关系层获取服务列表,然后批量获取实体
+ # 这里暂时返回空列表,等待 RelationshipManager 实现后再完善
+
+ logger.debug(
+ f"[SERVICE_ENTITY] List agent services (simplified version): agent_id={agent_id}"
+ )
+
+ return []
+
+ async def get_many_services(
+ self,
+ global_names: List[str]
+ ) -> List[Optional[ServiceEntity]]:
+ """
+ 批量获取服务实体
+
+ Args:
+ global_names: 服务全局名称列表
+
+ Returns:
+ 服务实体列表,不存在的服务返回 None
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not isinstance(global_names, list):
+ raise ValueError(
+ f"global_names must be a list type, actual type: {type(global_names).__name__}"
+ )
+
+ if not global_names:
+ return []
+
+ # 批量获取
+ data_list = await self._cache_layer.get_many_entities(
+ "services",
+ global_names
+ )
+
+ # 转换为实体对象
+ entities = []
+ for i, data in enumerate(data_list):
+ if data is None:
+ entities.append(None)
+ else:
+ try:
+ entity = ServiceEntity.from_dict(data)
+ entities.append(entity)
+ except Exception as e:
+ logger.error(
+ f"[SERVICE_ENTITY] Failed to parse service entity: "
+ f"global_name={global_names[i]}, error={e}"
+ )
+ # 解析失败时返回 None
+ entities.append(None)
+
+ logger.debug(
+ f"[SERVICE_ENTITY] Batch retrieved services: count={len(global_names)}, "
+ f"found={sum(1 for e in entities if e is not None)}"
+ )
+
+ return entities
+
+ def get_service_entity_sync(self, global_name: str) -> Optional[ServiceEntity]:
+ """
+ 同步获取服务实体 (Functional Core - 纯同步操作)
+
+ 严格按照核心原则:
+ 1. Functional Core: 纯同步操作,无IO,无副作用
+ 2. 使用现有同步接口,遵循架构模式
+
+ Args:
+ global_name: 服务全局名称
+
+ Returns:
+ ServiceEntity 如果存在,否则 None
+ """
+ # Functional Core: 使用现有的同步接口获取所有服务
+ all_entities = self._cache_layer.get_all_entities_sync("services")
+
+ # 纯函数操作:从字典中查找指定的实体
+ entity_data = all_entities.get(global_name)
+
+ if entity_data:
+ return ServiceEntity.from_dict(entity_data)
+ return None
diff --git a/src/mcpstore/core/cache/state_manager.py b/src/mcpstore/core/cache/state_manager.py
new file mode 100644
index 00000000..5b7e2973
--- /dev/null
+++ b/src/mcpstore/core/cache/state_manager.py
@@ -0,0 +1,447 @@
+"""
+状态管理器
+
+管理服务和工具的运行时状态。
+"""
+
+import logging
+import time
+from datetime import datetime
+from typing import Dict, List, Optional, Any
+
+from .cache_layer_manager import CacheLayerManager
+from .models import ServiceStatus, ToolStatusItem
+
+logger = logging.getLogger(__name__)
+
+
+class StateManager:
+ """
+ 状态管理器
+
+ 负责管理服务和工具的运行时状态,包括健康状态、工具可用性等。
+ 所有状态数据存储在状态层。
+ """
+
+ def __init__(self, cache_layer: CacheLayerManager):
+ """
+ 初始化状态管理器
+
+ Args:
+ cache_layer: 缓存层管理器
+ """
+ self._cache_layer = cache_layer
+ logger.debug("[StateManager] State manager initialization completed")
+
+ async def update_service_status(
+ self,
+ service_global_name: str,
+ health_status: str,
+ tools_status: List[Dict[str, Any]],
+ connection_attempts: int = 0,
+ max_connection_attempts: int = 3,
+ current_error: Optional[str] = None
+ ) -> None:
+ """
+ 更新服务状态
+
+ Args:
+ service_global_name: 服务全局名称
+ health_status: 健康状态 ("healthy" | "unhealthy" | "unknown")
+ tools_status: 工具状态列表
+ connection_attempts: 连接尝试次数
+ max_connection_attempts: 最大连接尝试次数
+ current_error: 当前错误信息
+
+ Raises:
+ ValueError: 如果健康状态值无效
+ """
+ # 验证健康状态
+ valid_health_statuses = ["healthy", "unhealthy", "unknown", "initializing", "warning", "disconnected", "reconnecting"]
+ if health_status not in valid_health_statuses:
+ raise ValueError(
+ f"Invalid health status: {health_status}. "
+ f"Valid values: {valid_health_statuses}"
+ )
+
+ # 验证工具状态
+ tools = []
+ for tool_status in tools_status:
+ if not isinstance(tool_status, dict):
+ raise ValueError(
+ f"Tool status must be a dictionary type, actual type: {type(tool_status).__name__}"
+ )
+
+ # 创建 ToolStatusItem 进行验证
+ tool_item = ToolStatusItem.from_dict(tool_status)
+ tools.append(tool_item)
+
+ # 创建服务状态对象
+ status = ServiceStatus(
+ service_global_name=service_global_name,
+ health_status=health_status,
+ last_health_check=int(time.time()),
+ connection_attempts=connection_attempts,
+ max_connection_attempts=max_connection_attempts,
+ current_error=current_error,
+ tools=tools
+ )
+
+ # 存储到状态层
+ await self._cache_layer.put_state(
+ "service_status",
+ service_global_name,
+ status.to_dict()
+ )
+
+ logger.debug(
+ f"[StateManager] Updated service status: service={service_global_name}, "
+ f"health={health_status}, tools_count={len(tools)}"
+ )
+
+ async def get_service_status(
+ self,
+ service_global_name: str
+ ) -> Optional[ServiceStatus]:
+ """
+ 获取服务状态
+
+ Args:
+ service_global_name: 服务全局名称
+
+ Returns:
+ 服务状态对象,如果不存在则返回 None
+ """
+ status_data = await self._cache_layer.get_state(
+ "service_status",
+ service_global_name
+ )
+
+ if status_data is None:
+ logger.debug(
+ f"[StateManager] Service status not found: service={service_global_name}"
+ )
+ return None
+
+ status = ServiceStatus.from_dict(status_data)
+
+ if status.health_status != "healthy":
+ logger.debug(
+ f"[StateManager] Retrieved service status: service={service_global_name}, "
+ f"health={status.health_status}"
+ )
+
+ return status
+
+ async def update_tool_status(
+ self,
+ service_global_name: str,
+ tool_global_name: str,
+ status: str
+ ) -> None:
+ """
+ 更新工具状态
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_global_name: 工具全局名称
+ status: 工具状态 ("available" | "unavailable")
+
+ Raises:
+ ValueError: 如果工具状态值无效
+ RuntimeError: 如果服务状态不存在
+ """
+ # 验证工具状态
+ valid_statuses = ["available", "unavailable"]
+ if status not in valid_statuses:
+ raise ValueError(
+ f"Invalid tool status: {status}. "
+ f"Valid values: {valid_statuses}"
+ )
+
+ # 获取当前服务状态
+ service_status = await self.get_service_status(service_global_name)
+
+ if service_status is None:
+ raise RuntimeError(
+ f"Service status does not exist, cannot update tool status: "
+ f"service={service_global_name}, tool={tool_global_name}"
+ )
+
+ # 查找并更新工具状态
+ tool_found = False
+ for tool in service_status.tools:
+ if tool.tool_global_name == tool_global_name:
+ tool.status = status
+ tool_found = True
+ break
+
+ if not tool_found:
+ raise RuntimeError(
+ f"Tool does not exist in service status: "
+ f"service={service_global_name}, tool={tool_global_name}"
+ )
+
+ # 保存更新后的服务状态
+ await self._cache_layer.put_state(
+ "service_status",
+ service_global_name,
+ service_status.to_dict()
+ )
+
+ logger.debug(
+ f"[StateManager] Updated tool status: service={service_global_name}, "
+ f"tool={tool_global_name}, status={status}"
+ )
+
+ async def delete_service_status(self, service_global_name: str) -> None:
+ """
+ 删除服务状态
+
+ Args:
+ service_global_name: 服务全局名称
+ """
+
+ await self._cache_layer.delete_state("service_status", service_global_name)
+
+ logger.debug(
+ f"[StateManager] Deleted service status: service={service_global_name}"
+ )
+
+ async def delete_service_metadata(self, service_global_name: str) -> None:
+ """
+ 删除服务元数据状态。
+
+ Args:
+ service_global_name: 服务全局名称
+ """
+ await self._cache_layer.delete_state("service_metadata", service_global_name)
+ logger.debug(
+ f"[StateManager] Deleted service metadata: service={service_global_name}"
+ )
+
+ async def set_tool_available(
+ self,
+ service_global_name: str,
+ tool_original_name: str
+ ) -> None:
+ """
+ 设置工具为可用状态
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_original_name: 工具原始名称
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具不存在
+ """
+ await self._update_tool_status_by_original_name(
+ service_global_name,
+ tool_original_name,
+ "available"
+ )
+
+ async def set_tool_unavailable(
+ self,
+ service_global_name: str,
+ tool_original_name: str
+ ) -> None:
+ """
+ 设置工具为不可用状态
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_original_name: 工具原始名称
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具不存在
+ """
+ await self._update_tool_status_by_original_name(
+ service_global_name,
+ tool_original_name,
+ "unavailable"
+ )
+
+ async def _update_tool_status_by_original_name(
+ self,
+ service_global_name: str,
+ tool_original_name: str,
+ status: str
+ ) -> None:
+ """
+ 通过原始工具名更新工具状态
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_original_name: 工具原始名称
+ status: 工具状态 ("available" | "unavailable")
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具不存在
+ """
+ # 获取当前服务状态
+ service_status = await self.get_service_status(service_global_name)
+
+ if service_status is None:
+ raise RuntimeError(
+ f"Service status does not exist, cannot update tool status: "
+ f"service={service_global_name}, tool={tool_original_name}"
+ )
+
+ # 查找并更新工具状态(通过原始名称)
+ tool_found = False
+ for tool in service_status.tools:
+ if tool.tool_original_name == tool_original_name:
+ tool.status = status
+ tool_found = True
+ break
+
+ if not tool_found:
+ raise RuntimeError(
+ f"Tool does not exist in service status: "
+ f"service={service_global_name}, tool_original_name={tool_original_name}"
+ )
+
+ # 保存更新后的服务状态
+ await self._cache_layer.put_state(
+ "service_status",
+ service_global_name,
+ service_status.to_dict()
+ )
+
+ logger.debug(
+ f"[StateManager] Updated tool status: service={service_global_name}, "
+ f"tool_original_name={tool_original_name}, status={status}"
+ )
+
+ async def batch_set_tools_status(
+ self,
+ service_global_name: str,
+ tool_original_names: List[str],
+ status: str
+ ) -> None:
+ """
+ 批量设置工具状态
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_original_names: 工具原始名称列表
+ status: 工具状态 ("available" | "unavailable")
+
+ Raises:
+ ValueError: 如果状态值无效
+ RuntimeError: 如果服务状态不存在或任何工具不存在
+ """
+ # 验证状态值
+ valid_statuses = ["available", "unavailable"]
+ if status not in valid_statuses:
+ raise ValueError(
+ f"Invalid tool status: {status}. "
+ f"Valid values: {valid_statuses}"
+ )
+
+ # 获取当前服务状态
+ service_status = await self.get_service_status(service_global_name)
+
+ if service_status is None:
+ raise RuntimeError(
+ f"Service status does not exist, cannot update tool status: "
+ f"service={service_global_name}"
+ )
+
+ # 批量更新工具状态
+ not_found_tools = []
+ for tool_original_name in tool_original_names:
+ tool_found = False
+ for tool in service_status.tools:
+ if tool.tool_original_name == tool_original_name:
+ tool.status = status
+ tool_found = True
+ break
+
+ if not tool_found:
+ not_found_tools.append(tool_original_name)
+
+ if not_found_tools:
+ raise RuntimeError(
+ f"The following tools do not exist in service status: "
+ f"service={service_global_name}, tools={not_found_tools}"
+ )
+
+ # 保存更新后的服务状态
+ await self._cache_layer.put_state(
+ "service_status",
+ service_global_name,
+ service_status.to_dict()
+ )
+
+ logger.debug(
+ f"[StateManager] Batch updated tool status: service={service_global_name}, "
+ f"tools_count={len(tool_original_names)}, status={status}"
+ )
+
+ # ==================== 同步方法 (Functional Core) ====================
+
+ def set_state_sync(self, service_global_name: str, state) -> None:
+ """
+ 同步设置服务状态 (Functional Core - 纯同步操作)
+
+ 严格按照核心原则:
+ 1. Functional Core: 纯同步操作,无IO,无副作用
+ 2. 通过缓存层的同步接口实现
+ 3. 简单直接的状态设置
+
+ Args:
+ service_global_name: 服务全局名称
+ state: 服务状态
+ """
+ try:
+ from mcpstore.core.models.service import ServiceConnectionState
+ # 转换为状态字典
+ if isinstance(state, ServiceConnectionState):
+ state_dict = {
+ "health_status": state,
+ "last_updated": str(datetime.now())
+ }
+ else:
+ state_dict = state
+
+ # Functional Core: 只准备数据,不进行IO操作
+ # IO操作应该在 Imperative Shell 层处理
+ # 这里我们返回需要保存的数据,由调用者决定如何保存
+ logger.debug(f"[StateManager] Preparing state data: {service_global_name} -> {state_dict}")
+ # 注意:这个方法应该由 Imperative Shell 的异步包装器调用
+ raise NotImplementedError("Please use update_service_status method in async context")
+ logger.debug(f"[StateManager] Synchronously setting state: {service_global_name} -> {state}")
+
+ except Exception as e:
+ logger.error(f"[StateManager] Failed to set state synchronously {service_global_name}: {e}")
+ raise
+
+ def set_metadata_sync(self, service_global_name: str, metadata) -> None:
+ """
+ 同步设置服务元数据 (Functional Core - 纯同步操作)
+
+ 严格按照核心原则:
+ 1. Functional Core: 纯同步操作,无IO,无副作用
+ 2. 通过缓存层的同步接口实现
+ 3. 简单直接的元数据设置
+
+ Args:
+ service_global_name: 服务全局名称
+ metadata: 服务元数据
+ """
+ try:
+ # 转换为元数据字典
+ if hasattr(metadata, 'to_dict'):
+ metadata_dict = metadata.to_dict()
+ else:
+ metadata_dict = metadata
+
+ # 使用缓存层的同步接口保存元数据
+ self._cache_layer.put_state_sync("service_metadata", service_global_name, metadata_dict)
+ logger.debug(f"[StateManager] Synchronously setting metadata: {service_global_name}")
+
+ except Exception as e:
+ logger.error(f"[StateManager] Failed to set metadata synchronously {service_global_name}: {e}")
+ raise
diff --git a/src/mcpstore/core/cache/tool_entity_manager.py b/src/mcpstore/core/cache/tool_entity_manager.py
new file mode 100644
index 00000000..1acb4392
--- /dev/null
+++ b/src/mcpstore/core/cache/tool_entity_manager.py
@@ -0,0 +1,339 @@
+"""
+工具实体管理器
+
+负责管理工具实体的 CRUD 操作。
+"""
+
+import hashlib
+import json
+import logging
+import time
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
+
+from .models import ToolEntity
+from .naming_service import NamingService
+
+if TYPE_CHECKING:
+ from .cache_layer_manager import CacheLayerManager
+
+logger = logging.getLogger(__name__)
+
+
+class ToolEntityManager:
+ """
+ 工具实体管理器
+
+ 管理工具实体的创建、查询和删除操作。
+ """
+
+ def __init__(
+ self,
+ cache_layer: 'CacheLayerManager',
+ naming: NamingService
+ ):
+ """
+ 初始化工具实体管理器
+
+ Args:
+ cache_layer: 缓存层管理器
+ naming: 命名服务
+ """
+ self._cache_layer = cache_layer
+ self._naming = naming
+ logger.debug("[TOOL_ENTITY] Initializing ToolEntityManager")
+
+ @staticmethod
+ def _generate_tool_hash(tool_def: Dict[str, Any]) -> str:
+ """
+ 生成工具定义的哈希值
+
+ Args:
+ tool_def: 工具定义
+
+ Returns:
+ SHA256 哈希值
+ """
+ # 将工具定义转换为稳定的 JSON 字符串
+ tool_json = json.dumps(tool_def, sort_keys=True)
+ # 计算 SHA256 哈希
+ hash_obj = hashlib.sha256(tool_json.encode('utf-8'))
+ return f"sha256:{hash_obj.hexdigest()}"
+
+ async def create_tool(
+ self,
+ service_global_name: str,
+ service_original_name: str,
+ source_agent: str,
+ tool_original_name: str,
+ tool_def: Dict[str, Any]
+ ) -> str:
+ """
+ 创建工具实体
+
+ Args:
+ service_global_name: 服务全局名称
+ service_original_name: 服务原始名称
+ source_agent: 来源 Agent ID
+ tool_original_name: 工具原始名称
+ tool_def: 工具定义(包含 description 和 input_schema)
+
+ Returns:
+ 工具全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果创建失败
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+ if not service_original_name:
+ raise ValueError("Service original name cannot be empty")
+ if not source_agent:
+ raise ValueError("Source Agent ID cannot be empty")
+ if not tool_original_name:
+ raise ValueError("Tool original name cannot be empty")
+ if not isinstance(tool_def, dict):
+ raise ValueError(
+ f"Tool definition must be a dictionary type, actual type: {type(tool_def).__name__}"
+ )
+
+ # 处理嵌套的工具定义格式
+ # 支持两种格式:
+ # 1. 直接格式: {"description": "...", "inputSchema": {...}}
+ # 2. 嵌套格式: {"type": "function", "function": {"description": "...", "parameters": {...}}}
+ actual_def = tool_def
+ if "function" in tool_def and isinstance(tool_def["function"], dict):
+ fn = tool_def["function"]
+ actual_def = {
+ "description": fn.get("description", ""),
+ "inputSchema": fn.get("parameters", fn.get("inputSchema", {})),
+ "name": fn.get("name", tool_original_name),
+ "display_name": fn.get("display_name", tool_original_name),
+ "service_name": fn.get("service_name", service_original_name)
+ }
+
+ # 验证工具定义包含必需字段
+ description = actual_def.get("description", "")
+ input_schema = actual_def.get("inputSchema", actual_def.get("parameters", {}))
+
+ # 生成工具全局名称
+ tool_global_name = self._naming.generate_tool_global_name(
+ service_global_name,
+ tool_original_name
+ )
+
+ # 生成工具哈希
+ tool_hash = self._generate_tool_hash(tool_def)
+
+ # 检查工具是否已存在(基于全局名称判断)
+ existing = await self._cache_layer.get_entity("tools", tool_global_name)
+ if existing:
+ # 全局名称相同,认为是同一个实体,更新配置
+ entity = ToolEntity(
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_original_name,
+ service_global_name=service_global_name,
+ service_original_name=service_original_name,
+ source_agent=source_agent,
+ description=description,
+ input_schema=input_schema,
+ created_time=existing.get("created_time", int(time.time())),
+ tool_hash=tool_hash
+ )
+
+ await self._cache_layer.put_entity(
+ "tools",
+ tool_global_name,
+ entity.to_dict()
+ )
+
+ logger.info(
+ f"[TOOL_ENTITY] Updated tool entity: tool_global_name={tool_global_name}, "
+ f"tool_original_name={tool_original_name}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ return tool_global_name
+
+ # 创建新工具实体
+ entity = ToolEntity(
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_original_name,
+ service_global_name=service_global_name,
+ service_original_name=service_original_name,
+ source_agent=source_agent,
+ description=description,
+ input_schema=input_schema,
+ created_time=int(time.time()),
+ tool_hash=tool_hash
+ )
+
+ # 存储到实体层
+ await self._cache_layer.put_entity(
+ "tools",
+ tool_global_name,
+ entity.to_dict()
+ )
+
+ logger.info(
+ f"[TOOL_ENTITY] Created tool entity: tool_global_name={tool_global_name}, "
+ f"tool_original_name={tool_original_name}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ return tool_global_name
+
+ async def get_tool(self, tool_global_name: str) -> Optional[ToolEntity]:
+ """
+ 获取工具实体
+
+ Args:
+ tool_global_name: 工具全局名称
+
+ Returns:
+ 工具实体,如果不存在返回 None
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not tool_global_name:
+ raise ValueError("Tool global name cannot be empty")
+
+ # 从实体层获取
+ data = await self._cache_layer.get_entity("tools", tool_global_name)
+
+ if data is None:
+ logger.debug(
+ f"[TOOL_ENTITY] Tool does not exist: tool_global_name={tool_global_name}"
+ )
+ return None
+
+ # 转换为实体对象
+ try:
+ entity = ToolEntity.from_dict(data)
+ logger.debug(
+ f"[TOOL_ENTITY] Retrieved tool entity: tool_global_name={tool_global_name}"
+ )
+ return entity
+ except Exception as e:
+ logger.error(
+ f"[TOOL_ENTITY] Failed to parse tool entity: "
+ f"tool_global_name={tool_global_name}, error={e}"
+ )
+ raise RuntimeError(
+ f"Failed to parse tool entity: tool_global_name={tool_global_name}, error={e}"
+ ) from e
+
+ async def delete_tool(self, tool_global_name: str) -> None:
+ """
+ 删除工具实体
+
+ Args:
+ tool_global_name: 工具全局名称
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果删除失败
+ """
+ if not tool_global_name:
+ raise ValueError("Tool global name cannot be empty")
+
+ # 从实体层删除
+ await self._cache_layer.delete_entity("tools", tool_global_name)
+
+ logger.info(
+ f"[TOOL_ENTITY] Deleted tool entity: tool_global_name={tool_global_name}"
+ )
+
+ async def list_tools_by_service(
+ self,
+ service_global_name: str
+ ) -> List[ToolEntity]:
+ """
+ 列出服务的所有工具
+
+ 注意:此方法需要配合 RelationshipManager 使用,
+ 先从关系层获取工具列表,再批量获取实体。
+
+ 这里提供一个简化版本,仅用于测试。
+ 实际使用时应该通过 RelationshipManager 获取工具列表。
+
+ Args:
+ service_global_name: 服务全局名称
+
+ Returns:
+ 工具实体列表
+
+ Raises:
+ ValueError: 如果参数无效
+ """
+ if not service_global_name:
+ raise ValueError("Service global name cannot be empty")
+
+ # 注意:这是一个简化实现
+ # 实际应该从关系层获取工具列表,然后批量获取实体
+ # 这里暂时返回空列表,等待 RelationshipManager 实现后再完善
+
+ logger.debug(
+ f"[TOOL_ENTITY] List service tools (simplified version): "
+ f"service_global_name={service_global_name}"
+ )
+
+ return []
+
+ async def get_many_tools(
+ self,
+ tool_global_names: List[str]
+ ) -> List[Optional[ToolEntity]]:
+ """
+ 批量获取工具实体
+
+ Args:
+ tool_global_names: 工具全局名称列表
+
+ Returns:
+ 工具实体列表,不存在的工具返回 None
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not isinstance(tool_global_names, list):
+ raise ValueError(
+ f"tool_global_names must be a list type, "
+ f"actual type: {type(tool_global_names).__name__}"
+ )
+
+ if not tool_global_names:
+ return []
+
+ # 批量获取
+ data_list = await self._cache_layer.get_many_entities(
+ "tools",
+ tool_global_names
+ )
+
+ # 转换为实体对象
+ entities = []
+ for i, data in enumerate(data_list):
+ if data is None:
+ entities.append(None)
+ else:
+ try:
+ entity = ToolEntity.from_dict(data)
+ entities.append(entity)
+ except Exception as e:
+ logger.error(
+ f"[TOOL_ENTITY] Failed to parse tool entity: "
+ f"tool_global_name={tool_global_names[i]}, error={e}"
+ )
+ # 解析失败时返回 None
+ entities.append(None)
+
+ logger.debug(
+ f"[TOOL_ENTITY] Batch retrieved tools: count={len(tool_global_names)}, "
+ f"found={sum(1 for e in entities if e is not None)}"
+ )
+
+ return entities
diff --git a/src/mcpstore/core/client_manager.py b/src/mcpstore/core/client_manager.py
deleted file mode 100644
index 39a32ac5..00000000
--- a/src/mcpstore/core/client_manager.py
+++ /dev/null
@@ -1,194 +0,0 @@
-import os
-import json
-import random
-import string
-from datetime import datetime
-from typing import Dict, Any, Optional, List
-import logging
-
-logger = logging.getLogger(__name__)
-
-# 将所有配置文件统一放在 data/defaults 目录下
-CLIENT_SERVICES_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'defaults', 'client_services.json')
-AGENT_CLIENTS_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'defaults', 'agent_clients.json')
-
-class ClientManager:
- """管理客户端配置的类"""
-
- def __init__(self, services_path: Optional[str] = None):
- """
- 初始化客户端管理器
-
- Args:
- services_path: 配置文件目录
- """
- self.services_path = services_path or CLIENT_SERVICES_PATH
- self._ensure_file()
- self.client_services = self.load_all_clients()
- self.main_client_id = "main_client" # 主客户端ID
- self._ensure_agent_clients_file()
-
- def _ensure_file(self):
- """确保客户端服务配置文件存在"""
- os.makedirs(os.path.dirname(self.services_path), exist_ok=True)
- if not os.path.exists(self.services_path):
- with open(self.services_path, 'w', encoding='utf-8') as f:
- json.dump({}, f)
-
- def _ensure_agent_clients_file(self):
- """确保agent-client映射文件存在"""
- os.makedirs(os.path.dirname(AGENT_CLIENTS_PATH), exist_ok=True)
- if not os.path.exists(AGENT_CLIENTS_PATH):
- with open(AGENT_CLIENTS_PATH, 'w', encoding='utf-8') as f:
- json.dump({}, f)
-
- def load_all_clients(self) -> Dict[str, Any]:
- """加载所有客户端配置"""
- with open(self.services_path, 'r', encoding='utf-8') as f:
- return json.load(f)
-
- def save_all_clients(self, data: Dict[str, Any]):
- """保存所有客户端配置"""
- with open(self.services_path, 'w', encoding='utf-8') as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- # 更新内存中的数据
- self.client_services = data.copy()
-
- def get_client_config(self, client_id: str) -> Optional[Dict[str, Any]]:
- """获取客户端配置"""
- # 每次都重新加载以确保数据最新
- self.client_services = self.load_all_clients()
- return self.client_services.get(client_id)
-
- def save_client_config(self, client_id: str, config: Dict[str, Any]):
- """保存客户端配置"""
- all_clients = self.load_all_clients()
- all_clients[client_id] = config
- self.save_all_clients(all_clients)
- logger.info(f"Saved config for client_id={client_id}")
-
- def generate_client_id(self) -> str:
- """生成唯一的客户端ID"""
- ts = datetime.now().strftime("%Y%m%d%H%M%S")
- rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
- return f"client_{ts}_{rand}"
-
- def create_client_config_from_names(self, service_names: List[str], mcp_config: Dict[str, Any]) -> Dict[str, Any]:
- """从服务名称列表生成新的客户端配置"""
- all_services = mcp_config.get("mcpServers", {})
- selected = {name: all_services[name] for name in service_names if name in all_services}
- return {"mcpServers": selected}
-
- def add_client(self, config: Dict[str, Any], client_id: Optional[str] = None) -> str:
- """
- 添加新的客户端配置
-
- Args:
- config: 客户端配置
- client_id: 可选的客户端ID,如果不提供则自动生成
-
- Returns:
- 使用的客户端ID
- """
- if not client_id:
- client_id = self.generate_client_id()
- self.client_services[client_id] = config
- self.save_client_config(client_id, config)
- return client_id
-
- def remove_client(self, client_id: str) -> bool:
- """
- 移除客户端配置
-
- Args:
- client_id: 要移除的客户端ID
-
- Returns:
- 是否成功移除
- """
- if client_id in self.client_services:
- del self.client_services[client_id]
- self.save_all_clients(self.client_services)
- return True
- return False
-
- def has_client(self, client_id: str) -> bool:
- """
- 检查客户端是否存在
-
- Args:
- client_id: 客户端ID
-
- Returns:
- 是否存在
- """
- # 每次检查都重新加载以确保数据最新
- self.client_services = self.load_all_clients()
- return client_id in self.client_services
-
- def get_all_clients(self) -> Dict[str, Any]:
- """
- 获取所有客户端配置
-
- Returns:
- 所有客户端配置的字典
- """
- # 每次获取都重新加载以确保数据最新
- self.client_services = self.load_all_clients()
- return self.client_services.copy()
-
- # === agent_clients.json 相关 ===
- def load_all_agent_clients(self) -> Dict[str, Any]:
- """加载所有agent-client映射"""
- self._ensure_agent_clients_file()
- with open(AGENT_CLIENTS_PATH, 'r', encoding='utf-8') as f:
- return json.load(f)
-
- def save_all_agent_clients(self, data: Dict[str, Any]):
- """保存agent-client映射"""
- with open(AGENT_CLIENTS_PATH, 'w', encoding='utf-8') as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
-
- def get_agent_clients(self, agent_id: str) -> List[str]:
- """
- 获取指定 agent 下的所有 client_id
- 如果是 main_client,返回所有 client_id
- """
- if agent_id == self.main_client_id:
- return list(self.get_all_clients().keys())
- data = self.load_all_agent_clients()
- return data.get(agent_id, [])
-
- def add_agent_client_mapping(self, agent_id: str, client_id: str):
- """添加agent-client映射"""
- if agent_id == self.main_client_id:
- logger.debug("Skipping mapping for main_client as it's handled automatically")
- return
- data = self.load_all_agent_clients()
- if agent_id not in data:
- data[agent_id] = [client_id]
- elif client_id not in data[agent_id]:
- data[agent_id].append(client_id)
- self.save_all_agent_clients(data)
- logger.info(f"Mapped agent_id={agent_id} to client_id={client_id}")
-
- def remove_agent_client_mapping(self, agent_id: str, client_id: str):
- """移除agent-client映射"""
- if agent_id == self.main_client_id:
- logger.warning("Cannot remove mapping for main_client")
- return
- data = self.load_all_agent_clients()
- if agent_id in data and client_id in data[agent_id]:
- data[agent_id].remove(client_id)
- if not data[agent_id]:
- del data[agent_id]
- self.save_all_agent_clients(data)
- logger.info(f"Removed mapping agent_id={agent_id} to client_id={client_id}")
-
- def get_main_client_ids(self) -> List[str]:
- """获取 main_client 下的所有 client_id"""
- return list(self.get_all_clients().keys())
-
- def is_valid_client(self, client_id: str) -> bool:
- """检查是否是有效的 client_id"""
- return self.has_client(client_id)
diff --git a/src/mcpstore/core/configuration/__init__.py b/src/mcpstore/core/configuration/__init__.py
new file mode 100644
index 00000000..102c411a
--- /dev/null
+++ b/src/mcpstore/core/configuration/__init__.py
@@ -0,0 +1,4 @@
+"""
+Configuration layer modules consolidating config processors and builders.
+"""
+
diff --git a/src/mcpstore/core/configuration/config_export_service.py b/src/mcpstore/core/configuration/config_export_service.py
new file mode 100644
index 00000000..9496c64b
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_export_service.py
@@ -0,0 +1,432 @@
+#!/usr/bin/env python3
+"""
+配置导出服务
+
+提供配置快照的导出功能,支持多种格式和输出方式
+"""
+
+import logging
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Union
+
+# 添加项目路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
+
+from mcpstore.config.toml_config import get_config
+from mcpstore.core.configuration.config_snapshot import (
+ ConfigSnapshotFormatter, ConfigSnapshotError
+)
+from mcpstore.core.configuration.config_snapshot_generator import ConfigSnapshotGenerator
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigExportService:
+ """配置导出服务"""
+
+ def __init__(self):
+ """初始化配置导出服务"""
+ self.generator = None
+ self._init_generator()
+
+ def _init_generator(self):
+ """初始化快照生成器"""
+ try:
+ config = get_config()
+ self.generator = ConfigSnapshotGenerator(config)
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to initialize configuration snapshot generator: {e}")
+ raise ConfigSnapshotError(f"Failed to initialize configuration export service: {e}")
+
+ async def export_config(self,
+ format: str = "table",
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = False,
+ output_file: Optional[Union[str, Path]] = None,
+ mask_sensitive: bool = True) -> str:
+ """
+ 导出配置快照
+
+ Args:
+ format: 输出格式 ("json", "yaml", "table")
+ categories: 要包含的配置分类列表
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+ output_file: 输出文件路径,None 表示返回字符串
+ mask_sensitive: 是否屏蔽敏感配置值
+
+ Returns:
+ str: 配置快照内容(如果 output_file 为 None)
+
+ Raises:
+ ConfigSnapshotError: 导出过程中的错误
+ """
+ if not self.generator:
+ raise ConfigSnapshotError("Configuration snapshot generator not initialized")
+
+ # 验证格式
+ if format not in ["json", "yaml", "table"]:
+ raise ConfigSnapshotError(f"Unsupported format: {format}, supported formats: json, yaml, table")
+
+ try:
+ # 生成配置快照
+ snapshot = await self.generator.generate_snapshot(
+ categories=categories,
+ key_pattern=key_pattern,
+ include_sensitive=include_sensitive
+ )
+
+ # 格式化输出
+ if format == "json":
+ content = ConfigSnapshotFormatter.format_json(snapshot, mask_sensitive=mask_sensitive)
+ elif format == "yaml":
+ content = ConfigSnapshotFormatter.format_yaml(snapshot, mask_sensitive=mask_sensitive)
+ else: # table
+ content = ConfigSnapshotFormatter.format_table(
+ snapshot, mask_sensitive=mask_sensitive, max_width=120
+ )
+
+ # 输出到文件或返回字符串
+ if output_file:
+ await self._write_to_file(content, output_file)
+ return f"Configuration exported to: {output_file}"
+ else:
+ return content
+
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to export configuration: {e}")
+ raise ConfigSnapshotError(f"Failed to export configuration: {e}")
+
+ async def _write_to_file(self, content: str, file_path: Union[str, Path]):
+ """写入内容到文件"""
+ try:
+ path = Path(file_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(content)
+
+ logger.info(f"[CONFIG_EXPORT] [SAVE] Configuration snapshot saved to: {path}")
+ except Exception as e:
+ raise ConfigSnapshotError(f"Failed to write file {file_path}: {e}")
+
+ async def get_config_summary(self) -> Dict[str, Any]:
+ """
+ 获取配置摘要信息
+
+ Returns:
+ Dict[str, Any]: 配置摘要
+ """
+ if not self.generator:
+ raise ConfigSnapshotError("Configuration snapshot generator not initialized")
+
+ try:
+ # 生成快照(不包含敏感配置)
+ snapshot = await self.generator.generate_snapshot(include_sensitive=False)
+
+ summary = {
+ "timestamp": snapshot.timestamp.isoformat(),
+ "total_items": snapshot.total_items,
+ "group_count": len(snapshot.groups),
+ "source_distribution": {
+ source.value: count for source, count in snapshot.source_summary.items()
+ },
+ "groups": {}
+ }
+
+ # 各组详细信息
+ for group_name, group in snapshot.groups.items():
+ summary["groups"][group_name] = {
+ "name": group.name,
+ "item_count": group.item_count,
+ "sensitive_count": group.get_sensitive_count(),
+ "dynamic_count": group.get_dynamic_count()
+ }
+
+ return summary
+
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to get configuration summary: {e}")
+ raise ConfigSnapshotError(f"Failed to get configuration summary: {e}")
+
+ async def search_config(self,
+ query: str,
+ include_sensitive: bool = False) -> Dict[str, Any]:
+ """
+ 搜索配置项
+
+ Args:
+ query: 搜索查询(键名或描述)
+ include_sensitive: 是否包含敏感配置
+
+ Returns:
+ Dict[str, Any]: 搜索结果
+ """
+ if not self.generator:
+ raise ConfigSnapshotError("Configuration snapshot generator not initialized")
+
+ try:
+ # 使用查询作为正则表达式过滤
+ snapshot = await self.generator.generate_snapshot(
+ key_pattern=query,
+ include_sensitive=include_sensitive
+ )
+
+ results = []
+ for group_name, group in snapshot.groups.items():
+ for item in group.items:
+ results.append({
+ "key": item.key,
+ "value": item.value,
+ "source": item.source.value,
+ "category": item.category,
+ "is_sensitive": item.is_sensitive,
+ "is_dynamic": item.is_dynamic,
+ "description": item.description,
+ "validation_info": item.validation_info
+ })
+
+ return {
+ "query": query,
+ "timestamp": snapshot.timestamp.isoformat(),
+ "result_count": len(results),
+ "results": results
+ }
+
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to search configuration: {e}")
+ raise ConfigSnapshotError(f"Failed to search configuration: {e}")
+
+ async def validate_config(self) -> Dict[str, Any]:
+ """
+ 验证配置的完整性和一致性
+
+ Returns:
+ Dict[str, Any]: 验证结果
+ """
+ if not self.generator:
+ raise ConfigSnapshotError("Configuration snapshot generator not initialized")
+
+ try:
+ # 生成完整快照
+ snapshot = await self.generator.generate_snapshot(include_sensitive=True)
+
+ validation_result = {
+ "timestamp": snapshot.timestamp.isoformat(),
+ "total_items": snapshot.total_items,
+ "valid": True,
+ "warnings": [],
+ "errors": [],
+ "statistics": {
+ "source_distribution": {
+ source.value: count for source, count in snapshot.source_summary.items()
+ },
+ "category_distribution": {},
+ "sensitive_items": 0,
+ "dynamic_items": 0
+ }
+ }
+
+ # 统计各类配置
+ for group_name, group in snapshot.groups.items():
+ validation_result["statistics"]["category_distribution"][group_name] = group.item_count
+ validation_result["statistics"]["sensitive_items"] += group.get_sensitive_count()
+ validation_result["statistics"]["dynamic_items"] += group.get_dynamic_count()
+
+ # 检查配置一致性
+ for group_name, group in snapshot.groups.items():
+ for item in group.items:
+ # 检查无效的来源
+ if item.source.value not in ["default", "toml", "kv", "env"]:
+ validation_result["warnings"].append(
+ f"Configuration item {item.key} has unknown source: {item.source.value}"
+ )
+
+ # 检查空值
+ if item.value is None or item.value == "":
+ validation_result["warnings"].append(
+ f"Configuration item {item.key} has empty value"
+ )
+
+ # 如果有错误,标记为无效
+ if validation_result["errors"]:
+ validation_result["valid"] = False
+
+ return validation_result
+
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to validate configuration: {e}")
+ raise ConfigSnapshotError(f"Failed to validate configuration: {e}")
+
+ async def export_diff(self,
+ baseline_file: Union[str, Path],
+ format: str = "table",
+ output_file: Optional[Union[str, Path]] = None) -> str:
+ """
+ 导出当前配置与基线的差异
+
+ Args:
+ baseline_file: 基线配置文件路径
+ format: 输出格式 ("json", "yaml", "table")
+ output_file: 输出文件路径
+
+ Returns:
+ str: 差异报告内容
+ """
+ try:
+ # 读取基线配置
+ baseline_path = Path(baseline_file)
+ if not baseline_path.exists():
+ raise ConfigSnapshotError(f"Baseline file not found: {baseline_file}")
+
+ import json
+ with open(baseline_path, 'r', encoding='utf-8') as f:
+ if baseline_path.suffix.lower() == '.json':
+ baseline_data = json.load(f)
+ else:
+ # 简单解析,假设是键值对格式
+ baseline_data = {}
+ for line in f:
+ if '=' in line and not line.strip().startswith('#'):
+ key, value = line.split('=', 1)
+ baseline_data[key.strip()] = value.strip()
+
+ # 生成当前配置快照
+ snapshot = await self.generator.generate_snapshot(include_sensitive=True)
+ current_config = {item.key: item.value for group in snapshot.groups.values() for item in group.items}
+
+ # 计算差异
+ diff = {
+ "timestamp": snapshot.timestamp.isoformat(),
+ "baseline_file": str(baseline_path),
+ "added": {},
+ "removed": {},
+ "modified": {},
+ "unchanged": {}
+ }
+
+ baseline_keys = set(baseline_data.keys())
+ current_keys = set(current_config.keys())
+
+ # 新增的配置
+ for key in current_keys - baseline_keys:
+ diff["added"][key] = current_config[key]
+
+ # 删除的配置
+ for key in baseline_keys - current_keys:
+ diff["removed"][key] = baseline_data[key]
+
+ # 修改的配置
+ for key in baseline_keys & current_keys:
+ if baseline_data[key] != current_config[key]:
+ diff["modified"][key] = {
+ "old": baseline_data[key],
+ "new": current_config[key]
+ }
+ else:
+ diff["unchanged"][key] = current_config[key]
+
+ # 格式化输出
+ if format == "json":
+ content = json.dumps(diff, indent=2, ensure_ascii=False)
+ elif format == "yaml":
+ try:
+ import yaml
+ content = yaml.dump(diff, default_flow_style=False, allow_unicode=True)
+ except ImportError:
+ content = "# PyYAML not installed, falling back to JSON\n" + \
+ json.dumps(diff, indent=2, ensure_ascii=False)
+ else: # table
+ content = self._format_diff_table(diff)
+
+ # 输出到文件或返回字符串
+ if output_file:
+ await self._write_to_file(content, output_file)
+ return f"Configuration diff exported to: {output_file}"
+ else:
+ return content
+
+ except Exception as e:
+ logger.error(f"[CONFIG_EXPORT] [ERROR] Failed to export configuration diff: {e}")
+ raise ConfigSnapshotError(f"Failed to export configuration diff: {e}")
+
+ def _format_diff_table(self, diff: Dict[str, Any]) -> str:
+ """格式化差异为表格"""
+ lines = []
+ lines.append("=" * 100)
+ lines.append(f"配置差异报告 - {diff['timestamp']}")
+ lines.append(f"基线文件: {diff['baseline_file']}")
+ lines.append("=" * 100)
+
+ # 新增配置
+ if diff["added"]:
+ lines.append(f"\n[ADDED] New configuration ({len(diff['added'])} items):")
+ lines.append("-" * 100)
+ for key, value in diff["added"].items():
+ lines.append(f" {key:<50} = {value}")
+
+ # 删除配置
+ if diff["removed"]:
+ lines.append(f"\nRemoved config ({len(diff['removed'])} items):")
+ lines.append("-" * 100)
+ for key, value in diff["removed"].items():
+ lines.append(f" {key:<50} = {value}")
+
+ # 修改配置
+ if diff["modified"]:
+ lines.append(f"\nModified config ({len(diff['modified'])} items):")
+ lines.append("-" * 100)
+ for key, change in diff["modified"].items():
+ lines.append(f" {key:<50}")
+ lines.append(f" Old value: {change['old']}")
+ lines.append(f" New value: {change['new']}")
+
+ # 未变更配置
+ if diff["unchanged"]:
+ lines.append(f"\n[UNCHANGED] Unchanged configuration ({len(diff['unchanged'])} items):")
+ lines.append("-" * 100)
+ for key, value in list(diff["unchanged"].items())[:10]: # 只显示前10项
+ lines.append(f" {key:<50} = {value}")
+ if len(diff["unchanged"]) > 10:
+ lines.append(f" ... {len(diff['unchanged']) - 10} more unchanged configuration items")
+
+ return "\n".join(lines)
+
+
+# 全局配置导出服务实例
+_export_service: Optional[ConfigExportService] = None
+
+
+def get_config_export_service() -> ConfigExportService:
+ """获取全局配置导出服务实例"""
+ global _export_service
+ if _export_service is None:
+ _export_service = ConfigExportService()
+ return _export_service
+
+
+# 便捷函数
+async def export_config_snapshot(**kwargs) -> str:
+ """便捷函数:导出配置快照"""
+ service = get_config_export_service()
+ return await service.export_config(**kwargs)
+
+
+async def get_config_summary() -> Dict[str, Any]:
+ """便捷函数:获取配置摘要"""
+ service = get_config_export_service()
+ return await service.get_config_summary()
+
+
+async def search_config_items(query: str, **kwargs) -> Dict[str, Any]:
+ """便捷函数:搜索配置项"""
+ service = get_config_export_service()
+ return await service.search_config(query, **kwargs)
+
+
+async def validate_current_config() -> Dict[str, Any]:
+ """便捷函数:验证当前配置"""
+ service = get_config_export_service()
+ return await service.validate_config()
diff --git a/src/mcpstore/core/configuration/config_processor.py b/src/mcpstore/core/configuration/config_processor.py
new file mode 100644
index 00000000..fb7cd08a
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_processor.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+"""
+Configuration Processor - handles conversion between user configuration and FastMCP configuration
+Lenient to users, strict to FastMCP
+"""
+
+import logging
+from copy import deepcopy
+from typing import Dict, Any
+
+logger = logging.getLogger(__name__)
+
+class ConfigProcessor:
+ """
+ Configuration Processor: handles conversion between user configuration and FastMCP configuration
+
+ Design philosophy:
+ 1. Lenient to users: allow extra fields, transport optional
+ 2. Strict to FastMCP: ensure format fully complies with requirements
+ 3. Intelligent inference: automatically handle transport field
+ """
+
+ # Standard fields supported by FastMCP
+ FASTMCP_REMOTE_FIELDS = {
+ "url", "transport", "headers", "timeout", "keep_alive"
+ }
+
+ FASTMCP_LOCAL_FIELDS = {
+ "command", "args", "env", "working_dir", "timeout"
+ }
+
+ # Supported transport types
+ VALID_TRANSPORTS = {
+ "streamable-http", "sse", "stdio"
+ }
+
+ @classmethod
+ def process_user_config_for_fastmcp(cls, user_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Convert user configuration to FastMCP-compatible configuration
+
+ Args:
+ user_config: User's original configuration
+
+ Returns:
+ FastMCP-compatible configuration
+ """
+ if not isinstance(user_config, dict) or "mcpServers" not in user_config:
+ logger.warning("Invalid config format, returning as-is")
+ return user_config
+
+ # Deep copy to avoid modifying original configuration
+ fastmcp_config = deepcopy(user_config)
+
+ # Process each service
+ services_to_remove = []
+ for service_name, service_config in fastmcp_config["mcpServers"].items():
+ try:
+ processed_config = cls._process_single_service(service_config)
+ fastmcp_config["mcpServers"][service_name] = processed_config
+ logger.debug(f"Successfully processed service '{service_name}' for FastMCP")
+ except Exception as e:
+ logger.error(f"Failed to process service '{service_name}': {e}")
+ # Provide more detailed error information
+ if "missing" in str(e).lower():
+ logger.warning(f"Service '{service_name}' has missing required fields - removing from FastMCP config")
+ elif "url" in str(e).lower() and "command" in str(e).lower():
+ logger.warning(f"Service '{service_name}' has conflicting url/command fields - removing from FastMCP config")
+ else:
+ logger.warning(f"Service '{service_name}' has configuration errors - removing from FastMCP config: {e}")
+
+ services_to_remove.append(service_name)
+ continue
+
+ # Remove problematic services
+ for service_name in services_to_remove:
+ del fastmcp_config["mcpServers"][service_name]
+
+ return fastmcp_config
+
+ @classmethod
+ def _process_single_service(cls, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Process single service configuration
+
+ Args:
+ service_config: Configuration of single service
+
+ Returns:
+ Processed service configuration
+ """
+ if not isinstance(service_config, dict):
+ return service_config
+
+ # Deep copy to avoid modifying original configuration
+ processed = deepcopy(service_config)
+
+ # Determine service type
+ if "url" in processed:
+ # Remote service
+ processed = cls._process_remote_service(processed)
+ elif "command" in processed:
+ # Local service
+ processed = cls._process_local_service(processed)
+ else:
+ logger.warning("Service config missing both 'url' and 'command', keeping as-is")
+
+ return processed
+
+ @classmethod
+ def _process_remote_service(cls, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Process remote service configuration
+
+ Args:
+ config: Remote service configuration
+
+ Returns:
+ Processed configuration
+ """
+ # 1. Intelligently infer transport field
+ config = cls._infer_transport(config)
+
+ # 2. Clean non-FastMCP fields (keep user-defined fields in logs)
+ user_fields = set(config.keys()) - cls.FASTMCP_REMOTE_FIELDS
+ if user_fields:
+ logger.debug(f"Removing user-defined fields for FastMCP: {user_fields}")
+
+ # 3. Keep only FastMCP supported fields
+ fastmcp_config = {
+ key: value for key, value in config.items()
+ if key in cls.FASTMCP_REMOTE_FIELDS
+ }
+
+ # 4. Ensure required fields exist
+ if "url" not in fastmcp_config:
+ raise ValueError("Remote service missing required 'url' field")
+
+ return fastmcp_config
+
+ @classmethod
+ def _process_local_service(cls, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Process local service configuration
+
+ Args:
+ config: Local service configuration
+
+ Returns:
+ Processed configuration
+ """
+ # 1. Remove transport field (local services don't need it)
+ if "transport" in config:
+ logger.debug("Removing 'transport' field from local service (not needed)")
+ config = deepcopy(config)
+ del config["transport"]
+
+ # 2. Clean non-FastMCP fields
+ user_fields = set(config.keys()) - cls.FASTMCP_LOCAL_FIELDS
+ if user_fields:
+ logger.debug(f"Removing user-defined fields for FastMCP: {user_fields}")
+
+ # 3. Keep only FastMCP supported fields
+ fastmcp_config = {
+ key: value for key, value in config.items()
+ if key in cls.FASTMCP_LOCAL_FIELDS
+ }
+
+ # 4. Ensure required fields exist
+ if "command" not in fastmcp_config:
+ raise ValueError("Local service missing required 'command' field")
+
+ return fastmcp_config
+
+ @classmethod
+ def _infer_transport(cls, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 智能推断transport字段
+
+ Args:
+ config: 服务配置
+
+ Returns:
+ 包含正确transport字段的配置
+ """
+ config = deepcopy(config)
+ url = config.get("url", "")
+
+ # 如果用户已经指定了transport,验证并保留
+ if "transport" in config:
+ transport = config["transport"]
+ if transport in cls.VALID_TRANSPORTS:
+ logger.debug(f"Using user-specified transport: {transport}")
+ return config
+ else:
+ logger.warning(f"Invalid transport '{transport}', will auto-infer")
+ del config["transport"]
+
+ # 自动推断transport
+ if "/sse" in url.lower():
+ # URL包含/sse,使用SSE传输
+ config["transport"] = "sse"
+ logger.debug(f"Auto-inferred transport 'sse' from URL: {url}")
+ else:
+ # 默认使用streamable-http
+ config["transport"] = "streamable-http"
+ logger.debug(f"Auto-inferred transport 'streamable-http' for URL: {url}")
+
+ return config
+
+ @classmethod
+ def validate_user_config(cls, config: Dict[str, Any]) -> tuple[bool, str]:
+ """
+ 验证用户配置的基本有效性(宽松验证)
+
+ Args:
+ config: 用户配置
+
+ Returns:
+ (是否有效, 错误信息)
+ """
+ try:
+ # 1. 检查基本结构
+ if not isinstance(config, dict):
+ return False, "Config must be a dictionary"
+
+ if "mcpServers" not in config:
+ return False, "Config missing 'mcpServers' field"
+
+ if not isinstance(config["mcpServers"], dict):
+ return False, "'mcpServers' must be a dictionary"
+
+ # 2. 检查每个服务
+ for service_name, service_config in config["mcpServers"].items():
+ if not isinstance(service_config, dict):
+ return False, f"Service '{service_name}' config must be a dictionary"
+
+ # 检查必要字段
+ has_url = "url" in service_config
+ has_command = "command" in service_config
+
+ if not has_url and not has_command:
+ return False, f"Service '{service_name}' missing both 'url' and 'command' fields"
+
+ if has_url and has_command:
+ return False, f"Service '{service_name}' cannot have both 'url' and 'command' fields"
+
+ return True, "Config is valid"
+
+ except Exception as e:
+ return False, f"Config validation error: {e}"
+
+ @classmethod
+ def get_user_friendly_error(cls, fastmcp_error: str) -> str:
+ """
+ 将FastMCP错误转换为用户友好的错误信息
+
+ Args:
+ fastmcp_error: FastMCP的原始错误信息
+
+ Returns:
+ 用户友好的错误信息
+ """
+ error_lower = fastmcp_error.lower()
+
+ # 配置验证错误
+ if "validation errors" in error_lower:
+ return "Service configuration has validation errors. This may be due to user-defined fields that are not supported by FastMCP."
+
+ if "field required" in error_lower:
+ return "Missing required field. Please ensure your service has either 'url' or 'command' field."
+
+ if "extra inputs are not permitted" in error_lower:
+ return "Configuration contains unsupported fields. MCPStore will automatically filter these for FastMCP compatibility."
+
+ if "input should be" in error_lower:
+ return "Invalid field value. Please check your service configuration format."
+
+ # 网络相关错误
+ if "getaddrinfo failed" in error_lower:
+ return "Cannot resolve the service URL. Please check the URL and network connection."
+
+ if "connection refused" in error_lower:
+ return "Connection refused. Please verify the service is running and accessible."
+
+ if "connection closed" in error_lower:
+ return "Connection was closed by the service. The service may not be ready or may have crashed."
+
+ if "timeout" in error_lower:
+ return "Connection timeout. The service may be slow to respond or unreachable."
+
+ if "connection" in error_lower:
+ return "Connection failed. Please verify the service is running and accessible."
+
+ # SSL/TLS 证书相关错误(英文提示)
+ if (
+ "certificate verify failed" in error_lower
+ or "ssl: certificate_verify_failed" in error_lower
+ or "certificate has expired" in error_lower
+ or ("ssl" in error_lower and "certificate" in error_lower)
+ or "sslerror" in error_lower
+ ):
+ return (
+ "SSL certificate verification failed: the certificate is expired or untrusted. "
+ "Please update the server certificate or configure a trusted CA bundle in development. "
+ "For internal testing, you may temporarily switch to HTTP to diagnose."
+ )
+
+ # TLS 握手失败(Node/undici 常见文案)
+ if "handshake failure" in error_lower or "sslv3 alert handshake failure" in error_lower:
+ return (
+ "TLS handshake failed. The upstream may require different TLS versions/ciphers or a proper CA trust store. "
+ "Please update Node/OpenSSL/CA bundle, verify the upstream URL, or temporarily test over HTTP in development."
+ )
+
+ # Node/undici fetch 错误(服务启动时上游拉取失败)
+ if "fetch failed" in error_lower and ("undici" in error_lower or "node" in error_lower or "typeerror" in error_lower):
+ return (
+ "Service failed to fetch upstream data during startup (Node/undici). "
+ "Check network connectivity, the upstream URL, and HTTPS/TLS requirements."
+ )
+
+ # 文件系统相关错误
+ if "no such file" in error_lower or "file not found" in error_lower:
+ return "Required file not found. Please ensure all command files exist and are accessible."
+
+ if "permission denied" in error_lower or "access denied" in error_lower:
+ return "Permission denied. Please check file permissions and execution rights."
+
+ # 返回原始错误(已经足够友好的情况)
+ return fastmcp_error
+
diff --git a/src/mcpstore/core/configuration/config_service.py b/src/mcpstore/core/configuration/config_service.py
new file mode 100644
index 00000000..bc669900
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_service.py
@@ -0,0 +1,786 @@
+"""
+MCPStore Configuration Service
+
+Provides runtime configuration management capabilities including:
+- Dynamic configuration key definitions
+- Configuration reading and validation
+- Safe configuration updates with TOML persistence
+- Configuration metadata and source tracking
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass, field
+
+# Import TOML libraries
+try:
+ import tomli
+ import tomli_w
+except ImportError:
+ tomli = None
+ tomli_w = None
+
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Union
+
+from ...config.toml_config import get_config, get_user_config_path
+
+from ...config.config_defaults import (
+ HealthCheckConfigDefaults,
+ ContentUpdateConfigDefaults,
+ MonitoringConfigDefaults,
+ CacheMemoryConfigDefaults,
+ CacheRedisConfigDefaults,
+ StandaloneConfigDefaults,
+ ServerConfigDefaults,
+)
+from .config_snapshot import (
+ ConfigSnapshot
+)
+from .config_snapshot_generator import ConfigSnapshotGenerator
+from .config_export_service import ConfigExportService
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigKeyType(Enum):
+ """Configuration key type enumeration."""
+ STRING = "string"
+ INTEGER = "integer"
+ FLOAT = "float"
+ BOOLEAN = "boolean"
+ LIST = "list"
+
+
+class ConfigUpdateResult(Enum):
+ """Configuration update result."""
+ SUCCESS = "success"
+ VALIDATION_ERROR = "validation_error"
+ READONLY_KEY = "readonly_key"
+ PERSISTENCE_ERROR = "persistence_error"
+ INTERNAL_ERROR = "internal_error"
+
+
+@dataclass
+class ConfigKeyMetadata:
+ """Metadata for a configuration key."""
+ key: str
+ key_type: ConfigKeyType
+ description: str
+ default_value: Any
+ min_value: Optional[Union[int, float]] = None
+ max_value: Optional[Union[int, float]] = None
+ allowed_values: Optional[List[Any]] = None
+ is_dynamic: bool = True # False means requires restart
+ is_sensitive: bool = False
+ category: str = "general"
+ requires_restart: bool = False
+
+
+@dataclass
+class ConfigUpdateResponse:
+ """Response for configuration update operation."""
+ result: ConfigUpdateResult
+ key: str
+ old_value: Optional[Any] = None
+ new_value: Optional[Any] = None
+ message: str = ""
+ timestamp: datetime = field(default_factory=datetime.now)
+ requires_restart: bool = False
+
+
+@dataclass
+class ConfigInfo:
+ """Information about a configuration key."""
+ key: str
+ value: Any
+ metadata: ConfigKeyMetadata
+ source: str # "toml", "kv", "default", "environment"
+ last_modified: Optional[datetime] = None
+
+
+class ConfigService:
+ """
+ Configuration service for runtime configuration management.
+
+ Provides safe configuration updates with validation and persistence.
+ """
+
+ def __init__(self):
+ """Initialize the configuration service."""
+ self._dynamic_keys: Set[str] = set()
+ self._key_metadata: Dict[str, ConfigKeyMetadata] = {}
+ self._update_lock = asyncio.Lock()
+ self._snapshot_generator: Optional[ConfigSnapshotGenerator] = None
+ self._export_service: Optional[ConfigExportService] = None
+ self._initialize_metadata()
+
+ def _initialize_metadata(self):
+ """Initialize configuration key metadata."""
+ # Lifecycle configuration
+ self._register_key_metadata(
+ "health_check.warning_failure_threshold",
+ ConfigKeyType.INTEGER,
+ "Number of consecutive failures before warning",
+ HealthCheckConfigDefaults().warning_failure_threshold,
+ min_value=1, max_value=10,
+ category="health_check"
+ )
+ self._register_key_metadata(
+ "health_check.reconnecting_failure_threshold",
+ ConfigKeyType.INTEGER,
+ "Number of consecutive failures before reconnecting",
+ HealthCheckConfigDefaults().reconnecting_failure_threshold,
+ min_value=1, max_value=20,
+ category="health_check"
+ )
+ self._register_key_metadata(
+ "health_check.max_reconnect_attempts",
+ ConfigKeyType.INTEGER,
+ "Maximum number of reconnection attempts",
+ HealthCheckConfigDefaults().max_reconnect_attempts,
+ min_value=1, max_value=100,
+ category="health_check"
+ )
+
+ # Content update configuration
+ self._register_key_metadata(
+ "content_update.tools_update_interval",
+ ConfigKeyType.FLOAT,
+ "Tools update interval in seconds",
+ ContentUpdateConfigDefaults().tools_update_interval,
+ min_value=60.0, max_value=3600.0,
+ category="content_update"
+ )
+ self._register_key_metadata(
+ "content_update.max_concurrent_updates",
+ ConfigKeyType.INTEGER,
+ "Maximum concurrent updates",
+ ContentUpdateConfigDefaults().max_concurrent_updates,
+ min_value=1, max_value=10,
+ category="content_update"
+ )
+
+ # Monitoring configuration
+ self._register_key_metadata(
+ "monitoring.health_check_seconds",
+ ConfigKeyType.INTEGER,
+ "Health check interval in seconds",
+ MonitoringConfigDefaults().health_check_seconds,
+ min_value=5, max_value=300,
+ category="monitoring"
+ )
+ self._register_key_metadata(
+ "monitoring.tools_update_hours",
+ ConfigKeyType.FLOAT,
+ "Tools update interval in hours",
+ MonitoringConfigDefaults().tools_update_hours,
+ min_value=0.1, max_value=24.0,
+ category="monitoring"
+ )
+ self._register_key_metadata(
+ "monitoring.enable_tools_update",
+ ConfigKeyType.BOOLEAN,
+ "Enable automatic tools update",
+ MonitoringConfigDefaults().enable_tools_update,
+ category="monitoring"
+ )
+
+ # Cache memory configuration
+ self._register_key_metadata(
+ "cache.memory.timeout",
+ ConfigKeyType.FLOAT,
+ "Memory cache timeout in seconds",
+ CacheMemoryConfigDefaults().timeout,
+ min_value=0.1, max_value=60.0,
+ category="cache"
+ )
+ self._register_key_metadata(
+ "cache.memory.retry_attempts",
+ ConfigKeyType.INTEGER,
+ "Memory cache retry attempts",
+ CacheMemoryConfigDefaults().retry_attempts,
+ min_value=1, max_value=10,
+ category="cache"
+ )
+ self._register_key_metadata(
+ "cache.memory.max_size",
+ ConfigKeyType.INTEGER,
+ "Memory cache maximum size (None for unlimited)",
+ CacheMemoryConfigDefaults().max_size,
+ min_value=1, max_value=10000,
+ category="cache"
+ )
+
+ # Cache Redis configuration (non-sensitive only)
+ self._register_key_metadata(
+ "cache.redis.timeout",
+ ConfigKeyType.FLOAT,
+ "Redis cache timeout in seconds",
+ CacheRedisConfigDefaults().timeout,
+ min_value=0.1, max_value=60.0,
+ category="cache"
+ )
+ self._register_key_metadata(
+ "cache.redis.retry_attempts",
+ ConfigKeyType.INTEGER,
+ "Redis cache retry attempts",
+ CacheRedisConfigDefaults().retry_attempts,
+ min_value=1, max_value=10,
+ category="cache"
+ )
+ self._register_key_metadata(
+ "cache.redis.max_connections",
+ ConfigKeyType.INTEGER,
+ "Redis cache maximum connections",
+ CacheRedisConfigDefaults().max_connections,
+ min_value=1, max_value=1000,
+ category="cache"
+ )
+
+ # Standalone configuration
+ self._register_key_metadata(
+ "standalone.heartbeat_interval_seconds",
+ ConfigKeyType.FLOAT,
+ "Heartbeat interval in seconds",
+ StandaloneConfigDefaults().heartbeat_interval_seconds,
+ min_value=1.0, max_value=300.0,
+ category="standalone"
+ )
+ self._register_key_metadata(
+ "standalone.http_timeout_seconds",
+ ConfigKeyType.FLOAT,
+ "HTTP timeout in seconds",
+ StandaloneConfigDefaults().http_timeout_seconds,
+ min_value=1.0, max_value=300.0,
+ category="standalone"
+ )
+ self._register_key_metadata(
+ "standalone.reconnection_interval_seconds",
+ ConfigKeyType.FLOAT,
+ "Reconnection interval in seconds",
+ StandaloneConfigDefaults().reconnection_interval_seconds,
+ min_value=1.0, max_value=1800.0,
+ category="standalone"
+ )
+ self._register_key_metadata(
+ "standalone.log_level",
+ ConfigKeyType.STRING,
+ "Log level",
+ StandaloneConfigDefaults().log_level,
+ allowed_values=["DEBUG", "INFO", "WARNING", "ERROR"],
+ category="standalone"
+ )
+ self._register_key_metadata(
+ "standalone.enable_debug",
+ ConfigKeyType.BOOLEAN,
+ "Enable debug mode",
+ StandaloneConfigDefaults().enable_debug,
+ category="standalone"
+ )
+
+ # Server configuration (only dynamic ones)
+ self._register_key_metadata(
+ "server.reload",
+ ConfigKeyType.BOOLEAN,
+ "Enable server auto-reload",
+ ServerConfigDefaults().reload,
+ category="server"
+ )
+ self._register_key_metadata(
+ "server.auto_open_browser",
+ ConfigKeyType.BOOLEAN,
+ "Auto-open browser on server start",
+ ServerConfigDefaults().auto_open_browser,
+ category="server"
+ )
+ self._register_key_metadata(
+ "server.show_startup_info",
+ ConfigKeyType.BOOLEAN,
+ "Show startup information",
+ ServerConfigDefaults().show_startup_info,
+ category="server"
+ )
+ # Note: ServerConfigDefaults doesn't have log_level field, so we use a default value
+ self._register_key_metadata(
+ "server.log_level",
+ ConfigKeyType.STRING,
+ "Server log level",
+ "info",
+ allowed_values=["debug", "info", "warning", "error", "critical"],
+ category="server"
+ )
+
+ logger.info(f"Initialized {len(self._key_metadata)} configuration keys with {len(self._dynamic_keys)} dynamic keys")
+
+ def _register_key_metadata(
+ self,
+ key: str,
+ key_type: ConfigKeyType,
+ description: str,
+ default_value: Any,
+ min_value: Optional[Union[int, float]] = None,
+ max_value: Optional[Union[int, float]] = None,
+ allowed_values: Optional[List[Any]] = None,
+ is_sensitive: bool = False,
+ category: str = "general",
+ requires_restart: bool = False
+ ):
+ """Register metadata for a configuration key."""
+ metadata = ConfigKeyMetadata(
+ key=key,
+ key_type=key_type,
+ description=description,
+ default_value=default_value,
+ min_value=min_value,
+ max_value=max_value,
+ allowed_values=allowed_values,
+ is_dynamic=not requires_restart, # Dynamic if not requiring restart
+ is_sensitive=is_sensitive,
+ category=category,
+ requires_restart=requires_restart
+ )
+ self._key_metadata[key] = metadata
+ if metadata.is_dynamic:
+ self._dynamic_keys.add(key)
+
+ async def get_config_info(self, key: str) -> Optional[ConfigInfo]:
+ """
+ Get information about a specific configuration key.
+
+ Args:
+ key: Configuration key
+
+ Returns:
+ ConfigInfo if key exists, None otherwise
+ """
+ if key not in self._key_metadata:
+ return None
+
+ metadata = self._key_metadata[key]
+
+ try:
+ config = get_config()
+ if config is None:
+ # Fallback to default value
+ return ConfigInfo(
+ key=key,
+ value=metadata.default_value,
+ metadata=metadata,
+ source="default"
+ )
+
+ # Get current value from MCPStoreConfig helper so that
+ # wrapped values stored as {"value": actual} in the
+ # config KV store are transparently unwrapped.
+ value = await config._get_config_value(key, metadata.default_value)
+
+ # Determine source
+ source = "default"
+ if value != metadata.default_value:
+ # Simplified source tracking: anything different from the
+ # default is considered coming from KV/TOML.
+ source = "kv" # Could be refined to distinguish TOML vs KV
+
+ return ConfigInfo(
+ key=key,
+ value=value,
+ metadata=metadata,
+ source=source
+ )
+ except Exception as e:
+ logger.error(f"Error getting config info for {key}: {e}")
+ return ConfigInfo(
+ key=key,
+ value=metadata.default_value,
+ metadata=metadata,
+ source="default"
+ )
+
+ async def list_configs(self, category: Optional[str] = None) -> List[ConfigInfo]:
+ """
+ List all configuration keys, optionally filtered by category.
+
+ Args:
+ category: Optional category filter
+
+ Returns:
+ List of ConfigInfo objects
+ """
+ configs = []
+
+ for key in self._key_metadata:
+ if category and self._key_metadata[key].category != category:
+ continue
+
+ config_info = await self.get_config_info(key)
+ if config_info:
+ configs.append(config_info)
+
+ return configs
+
+ async def update_config(
+ self,
+ key: str,
+ value: Any,
+ persist_to_toml: bool = True,
+ update_reason: Optional[str] = None
+ ) -> ConfigUpdateResponse:
+ """
+ Update a configuration key.
+
+ Args:
+ key: Configuration key to update
+ value: New value
+ persist_to_toml: Whether to persist the change to TOML file
+ update_reason: Optional reason for the update
+
+ Returns:
+ ConfigUpdateResponse with operation result
+ """
+ async with self._update_lock:
+ try:
+ # Check if key exists
+ if key not in self._key_metadata:
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.VALIDATION_ERROR,
+ key=key,
+ message=f"Unknown configuration key: {key}"
+ )
+
+ metadata = self._key_metadata[key]
+
+ # Check if key is dynamic
+ if not metadata.is_dynamic:
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.READONLY_KEY,
+ key=key,
+ message=f"Configuration key '{key}' is not dynamic and requires restart",
+ requires_restart=True
+ )
+
+ # Validate new value
+ validation_result = self._validate_value(key, value, metadata)
+ if not validation_result.is_valid:
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.VALIDATION_ERROR,
+ key=key,
+ message=validation_result.error_message
+ )
+
+ # Get current value
+ config = get_config()
+ if config is None:
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.INTERNAL_ERROR,
+ key=key,
+ message="Configuration system not initialized"
+ )
+
+ # Read current value via MCPStoreConfig helper to get the
+ # unwrapped scalar value instead of the underlying
+ # {"value": actual} dict stored in the KV backend.
+ old_value = await config._get_config_value(key, metadata.default_value)
+
+ # Convert value to appropriate type
+ typed_value = self._convert_value_type(value, metadata.key_type)
+
+ # Prepare value for storage: wrap non-dict values into
+ # {"value": actual} to satisfy py-key-value wrappers that
+ # expect dict[str, Any] as the stored value type.
+ if isinstance(typed_value, dict):
+ store_value = typed_value
+ else:
+ store_value = {"value": typed_value}
+
+ full_key = f"{config._namespace}.{key}"
+ await config._kv.put(full_key, store_value)
+ logger.info(f"Updated config {key}: {old_value} -> {typed_value}")
+
+ # Persist to TOML if requested
+ if persist_to_toml:
+ persist_result = await self._persist_to_toml(key, typed_value)
+ if not persist_result:
+ # Rollback KV change if TOML persistence failed.
+ # old_value is the unwrapped scalar/object, so we
+ # need to wrap it again when writing back to KV.
+ if isinstance(old_value, dict):
+ rollback_store_value = old_value
+ else:
+ rollback_store_value = {"value": old_value}
+ await config._kv.put(full_key, rollback_store_value)
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.PERSISTENCE_ERROR,
+ key=key,
+ old_value=old_value,
+ new_value=typed_value,
+ message="Failed to persist configuration to TOML file"
+ )
+
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.SUCCESS,
+ key=key,
+ old_value=old_value,
+ new_value=typed_value,
+ message=f"Successfully updated {key}",
+ requires_restart=metadata.requires_restart
+ )
+
+ except Exception as e:
+ logger.error(f"Error updating config {key}: {e}")
+ return ConfigUpdateResponse(
+ result=ConfigUpdateResult.INTERNAL_ERROR,
+ key=key,
+ message=f"Internal error: {str(e)}"
+ )
+
+ async def _persist_to_toml(self, key: str, value: Any) -> bool:
+ """
+ Persist configuration change to TOML file.
+
+ Args:
+ key: Configuration key
+ value: New value
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ # Get TOML file path
+ config_path = get_user_config_path()
+ if not config_path.exists():
+ logger.warning(f"Config file {config_path} does not exist, skipping TOML persistence")
+ return True # Not an error, just no file to update
+
+ # Read current TOML content
+ if tomli is None:
+ logger.error("tomli library not available, cannot read TOML file")
+ return False
+
+ with open(config_path, 'rb') as f:
+ toml_data = tomli.load(f)
+
+ # Navigate to the appropriate section
+ key_parts = key.split('.')
+ current_section = toml_data
+
+ # Navigate to parent section
+ for part in key_parts[:-1]:
+ if part not in current_section:
+ current_section[part] = {}
+ current_section = current_section[part]
+
+ # Update the value
+ final_key = key_parts[-1]
+ current_section[final_key] = value
+
+ # Write back to TOML file with backup
+ backup_path = config_path.with_suffix('.toml.backup')
+
+ # Create backup
+ if config_path.exists():
+ import shutil
+ shutil.copy2(config_path, backup_path)
+
+ # Write updated TOML
+ if tomli_w is None:
+ logger.error("tomli_w library not available, cannot write TOML file")
+ return False
+
+ with open(config_path, 'wb') as f:
+ tomli_w.dump(toml_data, f)
+
+ logger.info(f"Persisted config {key} to {config_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to persist config {key} to TOML: {e}")
+ return False
+
+ def _validate_value(self, key: str, value: Any, metadata: ConfigKeyMetadata) -> "ValidationResult":
+ """Validate a configuration value."""
+ try:
+ # Type validation
+ if metadata.key_type == ConfigKeyType.INTEGER:
+ int_value = int(value)
+ if metadata.min_value is not None and int_value < metadata.min_value:
+ return ValidationResult(False, f"Value {int_value} is below minimum {metadata.min_value}")
+ if metadata.max_value is not None and int_value > metadata.max_value:
+ return ValidationResult(False, f"Value {int_value} is above maximum {metadata.max_value}")
+
+ elif metadata.key_type == ConfigKeyType.FLOAT:
+ float_value = float(value)
+ if metadata.min_value is not None and float_value < metadata.min_value:
+ return ValidationResult(False, f"Value {float_value} is below minimum {metadata.min_value}")
+ if metadata.max_value is not None and float_value > metadata.max_value:
+ return ValidationResult(False, f"Value {float_value} is above maximum {metadata.max_value}")
+
+ elif metadata.key_type == ConfigKeyType.BOOLEAN:
+ if isinstance(value, str):
+ if value.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
+ return ValidationResult(False, f"Invalid boolean value: {value}")
+ elif not isinstance(value, bool):
+ return ValidationResult(False, f"Expected boolean, got {type(value)}")
+
+ elif metadata.key_type == ConfigKeyType.STRING:
+ if not isinstance(value, str):
+ return ValidationResult(False, f"Expected string, got {type(value)}")
+
+ if metadata.allowed_values and value not in metadata.allowed_values:
+ return ValidationResult(False, f"Value '{value}' not in allowed values: {metadata.allowed_values}")
+
+ return ValidationResult(True)
+ except Exception as e:
+ return ValidationResult(False, f"Validation error: {str(e)}")
+
+ def _convert_value_type(self, value: Any, target_type: ConfigKeyType) -> Any:
+ """Convert value to the target type."""
+ if target_type == ConfigKeyType.INTEGER:
+ return int(value)
+ elif target_type == ConfigKeyType.FLOAT:
+ return float(value)
+ elif target_type == ConfigKeyType.BOOLEAN:
+ if isinstance(value, str):
+ return value.lower() in ('true', '1', 'yes')
+ return bool(value)
+ elif target_type == ConfigKeyType.STRING:
+ return str(value)
+ else:
+ return value
+
+ def get_dynamic_keys(self) -> Set[str]:
+ """Get all dynamic configuration keys."""
+ return self._dynamic_keys.copy()
+
+ def get_categories(self) -> Set[str]:
+ """Get all configuration categories."""
+ return {metadata.category for metadata in self._key_metadata.values()}
+
+ def _get_snapshot_generator(self) -> ConfigSnapshotGenerator:
+ """Get or create snapshot generator."""
+ if self._snapshot_generator is None:
+ try:
+ config = get_config()
+ self._snapshot_generator = ConfigSnapshotGenerator(config)
+ except Exception as e:
+ logger.error(f"Failed to initialize snapshot generator: {e}")
+ raise RuntimeError(f"Failed to initialize configuration snapshot generator: {e}")
+ return self._snapshot_generator
+
+ def _get_export_service(self) -> ConfigExportService:
+ """Get or create export service."""
+ if self._export_service is None:
+ self._export_service = ConfigExportService()
+ return self._export_service
+
+ async def generate_config_snapshot(self,
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = True) -> ConfigSnapshot:
+ """
+ 生成配置快照
+
+ Args:
+ categories: 要包含的配置分类列表
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+
+ Returns:
+ ConfigSnapshot: 配置快照对象
+ """
+ generator = self._get_snapshot_generator()
+ return await generator.generate_snapshot(
+ categories=categories,
+ key_pattern=key_pattern,
+ include_sensitive=include_sensitive
+ )
+
+ async def export_config_snapshot(self,
+ format: str = "table",
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = False,
+ output_file: Optional[Union[str, Path]] = None,
+ mask_sensitive: bool = True) -> str:
+ """
+ 导出配置快照
+
+ Args:
+ format: 输出格式 ("json", "yaml", "table")
+ categories: 要包含的配置分类列表
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+ output_file: 输出文件路径,None 表示返回字符串
+ mask_sensitive: 是否屏蔽敏感配置值
+
+ Returns:
+ str: 配置快照内容或文件路径
+ """
+ export_service = self._get_export_service()
+ return await export_service.export_config(
+ format=format,
+ categories=categories,
+ key_pattern=key_pattern,
+ include_sensitive=include_sensitive,
+ output_file=output_file,
+ mask_sensitive=mask_sensitive
+ )
+
+ async def get_config_summary(self) -> Dict[str, Any]:
+ """
+ 获取配置摘要信息
+
+ Returns:
+ Dict[str, Any]: 配置摘要
+ """
+ export_service = self._get_export_service()
+ return await export_service.get_config_summary()
+
+ async def search_config(self,
+ query: str,
+ include_sensitive: bool = False) -> Dict[str, Any]:
+ """
+ 搜索配置项
+
+ Args:
+ query: 搜索查询(键名或描述)
+ include_sensitive: 是否包含敏感配置
+
+ Returns:
+ Dict[str, Any]: 搜索结果
+ """
+ export_service = self._get_export_service()
+ return await export_service.search_config(query, include_sensitive)
+
+ async def validate_config(self) -> Dict[str, Any]:
+ """
+ 验证配置的完整性和一致性
+
+ Returns:
+ Dict[str, Any]: 验证结果
+ """
+ export_service = self._get_export_service()
+ return await export_service.validate_config()
+
+
+@dataclass
+class ValidationResult:
+ """Result of value validation."""
+ is_valid: bool
+ error_message: str = ""
+
+
+# Global configuration service instance
+_config_service: Optional[ConfigService] = None
+
+
+def get_config_service() -> ConfigService:
+ """Get the global configuration service instance."""
+ global _config_service
+ if _config_service is None:
+ _config_service = ConfigService()
+ return _config_service
+
+
diff --git a/src/mcpstore/core/configuration/config_snapshot.py b/src/mcpstore/core/configuration/config_snapshot.py
new file mode 100644
index 00000000..1c5ff125
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_snapshot.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""
+配置快照与调试可观测性模块
+
+提供当前生效配置的快照导出能力,支持:
+- 按组/键过滤配置项
+- 显示配置值和来源(默认/TOML/KV/环境变量)
+- 敏感数据屏蔽
+- 多种输出格式(JSON/YAML/表格)
+"""
+
+import json
+import sys
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Union
+
+# 添加项目路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
+
+# 延迟导入配置默认值以避免依赖问题
+def _get_config_defaults():
+ try:
+ from mcpstore.config.config_defaults import (
+ HealthCheckConfigDefaults,
+ ContentUpdateConfigDefaults,
+ MonitoringConfigDefaults,
+ CacheConfigDefaults,
+ StandaloneConfigDefaults,
+ )
+ return {
+ 'HealthCheckConfigDefaults': HealthCheckConfigDefaults,
+ 'ContentUpdateConfigDefaults': ContentUpdateConfigDefaults,
+ 'MonitoringConfigDefaults': MonitoringConfigDefaults,
+ 'CacheConfigDefaults': CacheConfigDefaults,
+ 'StandaloneConfigDefaults': StandaloneConfigDefaults,
+ }
+ except ImportError:
+ # 如果导入失败,返回空的默认值
+ return {}
+
+
+class ConfigSource(Enum):
+ """配置来源枚举"""
+ DEFAULT = "default" # 硬编码默认值
+ TOML = "toml" # TOML 文件
+ KV = "kv" # KV 存储(运行时修改)
+ ENV = "env" # 环境变量
+ COMPUTED = "computed" # 计算得出的值
+
+
+@dataclass
+class ConfigItemSnapshot:
+ """单个配置项的快照"""
+ key: str # 配置键名(如 "health_check.failure_threshold")
+ value: Any # 配置值
+ source: ConfigSource # 配置来源
+ category: str # 配置分类(如 "health_check", "cache")
+ is_sensitive: bool = False # 是否为敏感配置
+ is_dynamic: bool = False # 是否为动态配置
+ description: Optional[str] = None # 配置描述
+ validation_info: Optional[str] = None # 验证信息(范围、枚举值等)
+
+
+@dataclass
+class ConfigGroupSnapshot:
+ """配置组快照(如 health_check 组、cache 组)"""
+ name: str # 组名
+ items: List[ConfigItemSnapshot] # 组内配置项
+ item_count: int = field(init=False) # 配置项数量
+
+ def __post_init__(self):
+ self.item_count = len(self.items)
+
+ def get_item_count(self) -> int:
+ """获取配置项数量"""
+ return len(self.items)
+
+ def get_sensitive_count(self) -> int:
+ """获取敏感配置项数量"""
+ return sum(1 for item in self.items if item.is_sensitive)
+
+ def get_dynamic_count(self) -> int:
+ """获取动态配置项数量"""
+ return sum(1 for item in self.items if item.is_dynamic)
+
+
+@dataclass
+class ConfigSnapshot:
+ """完整的配置快照"""
+ timestamp: datetime # 快照时间戳
+ groups: Dict[str, ConfigGroupSnapshot] # 配置组字典
+ total_items: int = field(init=False) # 总配置项数
+ source_summary: Dict[ConfigSource, int] = field(default_factory=dict) # 来源统计
+
+ def __post_init__(self):
+ self.total_items = sum(group.item_count for group in self.groups.values())
+ self._update_source_summary()
+
+ def _update_source_summary(self):
+ """更新来源统计"""
+ self.source_summary.clear()
+ for group in self.groups.values():
+ for item in group.items:
+ self.source_summary[item.source] = self.source_summary.get(item.source, 0) + 1
+
+ def get_group(self, name: str) -> Optional[ConfigGroupSnapshot]:
+ """获取指定配置组"""
+ return self.groups.get(name)
+
+ def get_all_keys(self) -> Set[str]:
+ """获取所有配置键名"""
+ return {item.key for group in self.groups.values() for item in group.items}
+
+ def filter_by_category(self, categories: Union[str, List[str]]) -> 'ConfigSnapshot':
+ """按分类过滤配置快照"""
+ if isinstance(categories, str):
+ categories = [categories]
+
+ filtered_groups = {}
+ for category in categories:
+ if category in self.groups:
+ filtered_groups[category] = self.groups[category]
+
+ return ConfigSnapshot(
+ timestamp=self.timestamp,
+ groups=filtered_groups
+ )
+
+ def filter_by_key_pattern(self, pattern: str) -> 'ConfigSnapshot':
+ """按键名模式过滤配置快照"""
+ import re
+ regex = re.compile(pattern, re.IGNORECASE)
+
+ filtered_groups = {}
+ for group_name, group in self.groups.items():
+ filtered_items = [
+ item for item in group.items
+ if regex.search(item.key)
+ ]
+ if filtered_items:
+ filtered_groups[group_name] = ConfigGroupSnapshot(
+ name=group_name,
+ items=filtered_items
+ )
+
+ return ConfigSnapshot(
+ timestamp=self.timestamp,
+ groups=filtered_groups
+ )
+
+ def to_dict(self, mask_sensitive: bool = True) -> Dict[str, Any]:
+ """转换为字典格式"""
+ result = {
+ "timestamp": self.timestamp.isoformat(),
+ "summary": {
+ "total_items": self.total_items,
+ "group_count": len(self.groups),
+ "source_distribution": {source.value: count for source, count in self.source_summary.items()}
+ },
+ "groups": {}
+ }
+
+ for group_name, group in self.groups.items():
+ group_dict = {
+ "name": group.name,
+ "item_count": group.item_count,
+ "sensitive_count": group.get_sensitive_count(),
+ "dynamic_count": group.get_dynamic_count(),
+ "items": []
+ }
+
+ for item in group.items:
+ item_dict = {
+ "key": item.key,
+ "value": "***MASKED***" if mask_sensitive and item.is_sensitive else item.value,
+ "source": item.source.value,
+ "category": item.category,
+ "is_sensitive": item.is_sensitive,
+ "is_dynamic": item.is_dynamic
+ }
+ if item.description:
+ item_dict["description"] = item.description
+ if item.validation_info:
+ item_dict["validation_info"] = item.validation_info
+
+ group_dict["items"].append(item_dict)
+
+ result["groups"][group_name] = group_dict
+
+ return result
+
+
+class ConfigSnapshotFormatter:
+ """配置快照格式化器"""
+
+ @staticmethod
+ def format_json(snapshot: ConfigSnapshot, mask_sensitive: bool = True, indent: int = 2) -> str:
+ """格式化为 JSON"""
+ return json.dumps(snapshot.to_dict(mask_sensitive), indent=indent, ensure_ascii=False)
+
+ @staticmethod
+ def format_yaml(snapshot: ConfigSnapshot, mask_sensitive: bool = True) -> str:
+ """格式化为 YAML"""
+ try:
+ import yaml
+ return yaml.dump(snapshot.to_dict(mask_sensitive), default_flow_style=False, allow_unicode=True)
+ except ImportError:
+ return "# PyYAML not installed, falling back to JSON\n" + \
+ ConfigSnapshotFormatter.format_json(snapshot, mask_sensitive)
+
+ @staticmethod
+ def format_table(snapshot: ConfigSnapshot, mask_sensitive: bool = True, max_width: int = 100) -> str:
+ """格式化为表格"""
+ lines = []
+ lines.append("=" * max_width)
+ lines.append(f"配置快照 - {snapshot.timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
+ lines.append("=" * max_width)
+ lines.append(f"总计: {snapshot.total_items} 项配置,{len(snapshot.groups)} 个组")
+
+ # 来源统计
+ source_lines = [f" {source.value}: {count}" for source, count in snapshot.source_summary.items()]
+ lines.append("来源分布:\n" + "\n".join(source_lines))
+ lines.append("")
+
+ # 按组显示
+ for group_name, group in snapshot.groups.items():
+ lines.append(f"【{group_name}】({group.item_count} 项,{group.get_sensitive_count()} 敏感,{group.get_dynamic_count()} 动态)")
+ lines.append("-" * max_width)
+
+ for item in group.items:
+ value_display = "***MASKED***" if mask_sensitive and item.is_sensitive else str(item.value)
+ line = f" {item.key:<35} = {value_display:<25} [{item.source.value}]"
+
+ if item.is_sensitive:
+ line += " [SENSITIVE]"
+ if item.is_dynamic:
+ line += " [DYNAMIC]"
+
+ lines.append(line)
+
+ if item.description:
+ lines.append(f" └─ {item.description}")
+ if item.validation_info:
+ lines.append(f" └─ 验证: {item.validation_info}")
+
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+class ConfigSnapshotError(Exception):
+ """配置快照相关异常"""
+ pass
diff --git a/src/mcpstore/core/configuration/config_snapshot_generator.py b/src/mcpstore/core/configuration/config_snapshot_generator.py
new file mode 100644
index 00000000..fa12b243
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_snapshot_generator.py
@@ -0,0 +1,375 @@
+#!/usr/bin/env python3
+"""
+配置快照生成器
+
+实现配置来源追踪逻辑,区分配置值的来源(默认/TOML/KV/环境变量)
+"""
+
+import logging
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import List
+
+import toml
+
+# 添加项目路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
+
+from mcpstore.config.config_defaults import *
+from mcpstore.config.toml_config import MCPStoreConfig, get_config
+from mcpstore.core.configuration.config_snapshot import (
+ ConfigSnapshot, ConfigGroupSnapshot, ConfigItemSnapshot, ConfigSource,
+ ConfigSnapshotError
+)
+# 避免循环导入,使用延迟导入
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ConfigTraceResult:
+ """配置追踪结果"""
+ value: Any
+ source: ConfigSource
+ original_value: Any = None # 原始值(用于类型转换前)
+
+
+class ConfigSnapshotGenerator:
+ """配置快照生成器"""
+
+ def __init__(self, config: Optional[MCPStoreConfig] = None):
+ """
+ 初始化配置快照生成器
+
+ Args:
+ config: MCPStoreConfig 实例,如果为 None 则使用全局配置
+ """
+ self.config = config or get_config()
+ if not self.config:
+ raise ConfigSnapshotError("MCPStoreConfig is not initialized, please call init_config() first")
+
+ # 缓存默认值以避免重复计算
+ self._default_values_cache: Optional[Dict[str, Any]] = None
+ self._toml_values_cache: Optional[Dict[str, Any]] = None
+
+ # 敏感配置键模式
+ self._sensitive_patterns = {
+ "password", "secret", "token", "key", "auth", "credential",
+ "redis_url", "database_url", "connection_string"
+ }
+
+ # 配置分类定义
+ self._category_mappings = {
+ "health_check": {
+ "prefix": "health_check.",
+ "description": "健康检查配置"
+ },
+ "content_update": {
+ "prefix": "content_update.",
+ "description": "内容更新配置"
+ },
+ "monitoring": {
+ "prefix": "monitoring.",
+ "description": "监控配置"
+ },
+ "cache": {
+ "prefix": "cache.",
+ "description": "缓存配置"
+ },
+ "standalone": {
+ "prefix": "standalone.",
+ "description": "独立应用配置"
+ },
+ "server": {
+ "prefix": "server.",
+ "description": "API 服务器配置"
+ }
+ }
+
+ def _is_sensitive_key(self, key: str) -> bool:
+ """判断配置键是否为敏感配置"""
+ key_lower = key.lower()
+ return any(pattern in key_lower for pattern in self._sensitive_patterns)
+
+ def _get_category_for_key(self, key: str) -> str:
+ """根据键名确定配置分类"""
+ for category, config in self._category_mappings.items():
+ if key.startswith(config["prefix"]):
+ return category
+ return "other"
+
+ def _get_default_values(self) -> Dict[str, Any]:
+ """获取所有配置的默认值"""
+ if self._default_values_cache is None:
+ self._default_values_cache = self._compute_default_values()
+ return self._default_values_cache
+
+ def _compute_default_values(self) -> Dict[str, Any]:
+ """计算所有配置的默认值"""
+ defaults = {}
+
+ # 生命周期与健康检查默认值
+ lifecycle_defaults = HealthCheckConfigDefaults()
+ defaults.update({
+ "health_check.enabled": lifecycle_defaults.enabled,
+ "health_check.check_interval_seconds": lifecycle_defaults.check_interval_seconds,
+ "health_check.failure_threshold": lifecycle_defaults.failure_threshold,
+ "health_check.warning_failure_threshold": lifecycle_defaults.warning_failure_threshold,
+ "health_check.termination_failure_threshold": lifecycle_defaults.termination_failure_threshold,
+ "health_check.initialization_timeout_seconds": lifecycle_defaults.initialization_timeout_seconds,
+ "health_check.shutdown_timeout_seconds": lifecycle_defaults.shutdown_timeout_seconds,
+ "health_check.restart_delay_seconds": lifecycle_defaults.restart_delay_seconds,
+ "health_check.health_check_timeout_seconds": lifecycle_defaults.health_check_timeout_seconds,
+ "health_check.enableDetailedLogging": lifecycle_defaults.enableDetailedLogging,
+ "health_check.collectStartupMetrics": lifecycle_defaults.collectStartupMetrics,
+ "health_check.collectRuntimeMetrics": lifecycle_defaults.collectRuntimeMetrics,
+ "health_check.collectShutdownMetrics": lifecycle_defaults.collectShutdownMetrics,
+ })
+
+ # 内容更新默认值
+ content_defaults = ContentUpdateConfigDefaults()
+ defaults.update({
+ "content_update.enabled": content_defaults.enabled,
+ "content_update.tools_update_interval": content_defaults.tools_update_interval,
+ "content_update.services_update_interval": content_defaults.services_update_interval,
+ "content_update.failure_threshold": content_defaults.failure_threshold,
+ "content_update.max_retry_attempts": content_defaults.max_retry_attempts,
+ "content_update.retry_delay_seconds": content_defaults.retry_delay_seconds,
+ "content_update.enable_detailed_logging": content_defaults.enable_detailed_logging,
+ })
+
+ # 监控配置默认值
+ monitoring_defaults = MonitoringConfigDefaults()
+ defaults.update({
+ "monitoring.enabled": monitoring_defaults.enabled,
+ "monitoring.health_check_seconds": monitoring_defaults.health_check_seconds,
+ "monitoring.metrics_collection_seconds": monitoring_defaults.metrics_collection_seconds,
+ "monitoring.statistics_retention_hours": monitoring_defaults.statistics_retention_hours,
+ "monitoring.max_statistics_memory_mb": monitoring_defaults.max_statistics_memory_mb,
+ "monitoring.enable_performance_monitoring": monitoring_defaults.enable_performance_monitoring,
+ "monitoring.enable_tools_update": monitoring_defaults.enable_tools_update,
+ "monitoring.slow_query_threshold_seconds": monitoring_defaults.slow_query_threshold_seconds,
+ "monitoring.memory_usage_warning_threshold": monitoring_defaults.memory_usage_warning_threshold,
+ "monitoring.cpu_usage_warning_threshold": monitoring_defaults.cpu_usage_warning_threshold,
+ "monitoring.enable_detailed_logging": monitoring_defaults.enable_detailed_logging,
+ "monitoring.log_slow_operations": monitoring_defaults.log_slow_operations,
+ "monitoring.export_format": monitoring_defaults.export_format,
+ })
+
+ # 缓存配置默认值(非敏感部分)
+ cache_defaults = CacheConfigDefaults()
+ defaults.update({
+ "cache.type": "memory", # 默认内存缓存
+ "cache.memory.max_size": cache_defaults.memory.max_size,
+ "cache.memory.ttl_seconds": cache_defaults.memory.ttl_seconds,
+ "cache.memory.cleanup_interval_seconds": cache_defaults.memory.cleanup_interval_seconds,
+ "cache.redis.max_connections": cache_defaults.redis.max_connections,
+ "cache.redis.socket_timeout_seconds": cache_defaults.redis.socket_timeout_seconds,
+ "cache.redis.socket_connect_timeout_seconds": cache_defaults.redis.socket_connect_timeout_seconds,
+ "cache.redis.health_check_interval_seconds": cache_defaults.redis.health_check_interval_seconds,
+ "cache.redis.max_retries": cache_defaults.redis.max_retries,
+ "cache.redis.retry_delay_seconds": cache_defaults.redis.retry_delay_seconds,
+ })
+
+ # 独立应用配置默认值
+ standalone_defaults = StandaloneConfigDefaults()
+ defaults.update({
+ "standalone.heartbeat_interval_seconds": standalone_defaults.heartbeat_interval_seconds,
+ "standalone.http_timeout_seconds": standalone_defaults.http_timeout_seconds,
+ "standalone.reconnection_interval_seconds": standalone_defaults.reconnection_interval_seconds,
+ "standalone.cleanup_interval_seconds": standalone_defaults.cleanup_interval_seconds,
+ "standalone.streamable_http_endpoint": standalone_defaults.streamable_http_endpoint,
+ "standalone.default_transport": standalone_defaults.default_transport,
+ "standalone.log_level": standalone_defaults.log_level,
+ "standalone.enable_debug": standalone_defaults.enable_debug,
+ })
+
+ # 服务器配置默认值
+ defaults.update({
+ "server.host": "0.0.0.0",
+ "server.port": 18200,
+ "server.reload": False,
+ "server.auto_open_browser": False,
+ "server.show_startup_info": True,
+ "server.log_level": "info",
+ "server.url_prefix": "",
+ })
+
+ return defaults
+
+ async def _get_toml_values(self) -> Dict[str, Any]:
+ """获取 TOML 文件中的配置值"""
+ if self._toml_values_cache is None:
+ self._toml_values_cache = await self._load_toml_values()
+ return self._toml_values_cache
+
+ async def _load_toml_values(self) -> Dict[str, Any]:
+ """从 TOML 文件加载配置值"""
+ toml_values = {}
+
+ try:
+ config_path = Path.home() / ".mcpstore" / "config.toml"
+ if config_path.exists():
+ with open(config_path, 'r', encoding='utf-8') as f:
+ toml_data = toml.load(f)
+
+ # 扁平化 TOML 数据
+ toml_values = self._flatten_dict(toml_data)
+ except Exception as e:
+ logger.warning(f"[CONFIG_SNAPSHOT] [WARN] Failed to load TOML configuration file: {e}")
+
+ return toml_values
+
+ def _flatten_dict(self, data: Dict[str, Any], prefix: str = "", separator: str = ".") -> Dict[str, Any]:
+ """扁平化嵌套字典"""
+ result = {}
+
+ for key, value in data.items():
+ full_key = f"{prefix}{separator}{key}" if prefix else key
+
+ if isinstance(value, dict):
+ result.update(self._flatten_dict(value, full_key, separator))
+ else:
+ result[full_key] = value
+
+ return result
+
+ async def _trace_config_value(self, key: str, default_value: Any) -> ConfigTraceResult:
+ """
+ 追踪配置值的来源
+
+ 优先级:KV 存储 > TOML 文件 > 默认值
+ """
+ # 1. 检查 KV 存储
+ kv_key = f"config.{key}"
+ try:
+ kv_value = await self.config._kv.get(kv_key)
+ if kv_value is not None:
+ return ConfigTraceResult(
+ value=kv_value,
+ source=ConfigSource.KV,
+ original_value=kv_value
+ )
+ except Exception as e:
+ logger.warning(f"[CONFIG_SNAPSHOT] [WARN] Failed to read KV configuration {kv_key}: {e}")
+
+ # 2. 检查 TOML 文件
+ toml_values = await self._get_toml_values()
+ if key in toml_values:
+ return ConfigTraceResult(
+ value=toml_values[key],
+ source=ConfigSource.TOML,
+ original_value=toml_values[key]
+ )
+
+ # 3. 使用默认值
+ return ConfigTraceResult(
+ value=default_value,
+ source=ConfigSource.DEFAULT,
+ original_value=default_value
+ )
+
+ async def _get_dynamic_keys_metadata(self) -> Dict[str, Dict[str, Any]]:
+ """获取动态配置键的元数据"""
+ try:
+ # 延迟导入避免循环依赖
+ from mcpstore.core.configuration.config_service import get_config_service
+ config_service = get_config_service()
+ return config_service.get_all_metadata()
+ except Exception as e:
+ logger.warning(f"[CONFIG_SNAPSHOT] [WARN] Failed to get dynamic configuration metadata: {e}")
+ return {}
+
+ async def generate_snapshot(self,
+ categories: Optional[List[str]] = None,
+ key_pattern: Optional[str] = None,
+ include_sensitive: bool = True) -> ConfigSnapshot:
+ """
+ 生成配置快照
+
+ Args:
+ categories: 要包含的配置分类,None 表示包含所有
+ key_pattern: 键名过滤模式(正则表达式)
+ include_sensitive: 是否包含敏感配置
+
+ Returns:
+ ConfigSnapshot: 配置快照对象
+ """
+ import re
+
+ start_time = datetime.now()
+ logger.info(f"[CONFIG_SNAPSHOT] [START] Starting to generate configuration snapshot (categories={categories}, pattern={key_pattern})")
+
+ # 获取所有默认值
+ default_values = self._get_default_values()
+ dynamic_metadata = await self._get_dynamic_keys_metadata()
+
+ # 收集配置项
+ all_items = []
+
+ for key, default_value in default_values.items():
+ # 应用分类过滤
+ category = self._get_category_for_key(key)
+ if categories and category not in categories:
+ continue
+
+ # 应用键名模式过滤
+ if key_pattern and not re.search(key_pattern, key, re.IGNORECASE):
+ continue
+
+ # 追踪配置值来源
+ trace_result = await self._trace_config_value(key, default_value)
+
+ # 获取元数据
+ metadata = dynamic_metadata.get(key, {})
+ is_dynamic = metadata.get("is_dynamic", False)
+ description = metadata.get("description")
+ validation_info = metadata.get("validation_info")
+
+ # 检查是否为敏感配置
+ is_sensitive = self._is_sensitive_key(key) or metadata.get("is_sensitive", False)
+
+ # 如果不包含敏感配置且当前是敏感配置,则跳过
+ if not include_sensitive and is_sensitive:
+ continue
+
+ # 创建配置项快照
+ item = ConfigItemSnapshot(
+ key=key,
+ value=trace_result.value,
+ source=trace_result.source,
+ category=category,
+ is_sensitive=is_sensitive,
+ is_dynamic=is_dynamic,
+ description=description,
+ validation_info=validation_info
+ )
+
+ all_items.append(item)
+
+ # 按分类分组
+ groups_dict = {}
+ for item in all_items:
+ if item.category not in groups_dict:
+ groups_dict[item.category] = []
+ groups_dict[item.category].append(item)
+
+ # 创建配置组快照
+ groups = {}
+ for category, items in groups_dict.items():
+ groups[category] = ConfigGroupSnapshot(
+ name=category,
+ items=items
+ )
+
+ # 创建完整快照
+ snapshot = ConfigSnapshot(
+ timestamp=start_time,
+ groups=groups
+ )
+
+ elapsed = (datetime.now() - start_time).total_seconds()
+ logger.info(f"[CONFIG_SNAPSHOT] [COMPLETE] Configuration snapshot generation completed, elapsed {elapsed:.2f}s, contains {snapshot.total_items} configuration items")
+
+ return snapshot
diff --git a/src/mcpstore/core/configuration/config_write_service.py b/src/mcpstore/core/configuration/config_write_service.py
new file mode 100644
index 00000000..cdf76dae
--- /dev/null
+++ b/src/mcpstore/core/configuration/config_write_service.py
@@ -0,0 +1,110 @@
+"""
+Atomic config write service with cross-platform advisory locking.
+
+Non-breaking introduction: this module is added but not yet integrated. It
+provides a single entrypoint `atomic_update` that callers can adopt to avoid
+read-modify-write races when updating JSON configs like mcp.json.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Callable, Dict, Any
+
+
+class ConfigWriteService:
+ """Utility for atomic JSON config updates with file locking."""
+
+ def __init__(self, lock_suffix: str = ".lock"):
+ self._lock_suffix = lock_suffix
+
+ def atomic_update(self, json_path: str, mutator: Callable[[Dict[str, Any]], Dict[str, Any]]) -> bool:
+ """Atomically update a JSON file with a user-provided mutator.
+
+ Steps:
+ - Acquire advisory lock file
+ - Read current JSON (or {} if missing)
+ - Apply mutator(config) -> new_config
+ - Write to temp file and atomically replace
+
+ Returns:
+ - bool: True on success
+ """
+ path = Path(json_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ with self._lock_file(path):
+ current: Dict[str, Any] = {}
+ if path.exists():
+ try:
+ with path.open("r", encoding="utf-8") as f:
+ current = json.load(f)
+ except Exception:
+ # Corrupt or empty, treat as empty structure
+ current = {}
+
+ new_config = mutator(dict(current)) or {}
+
+ # Serialize with stable formatting
+ data = json.dumps(new_config, ensure_ascii=False, indent=2)
+
+ # Write to temp file in same directory for atomic replace
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(data)
+ f.flush()
+ os.fsync(f.fileno())
+ # Atomic replace on POSIX; on Windows, replace should also be atomic for same-volume
+ os.replace(tmp, path)
+ return True
+ finally:
+ # If replace failed, ensure temp is removed
+ if os.path.exists(tmp):
+ try:
+ os.remove(tmp)
+ except Exception:
+ pass
+
+ @contextmanager
+ def _lock_file(self, target: Path):
+ """Advisory lock via lock file creation; best-effort cross-platform.
+
+ This is intentionally simple: exclusive create, retry quickly.
+ For higher contention, consider portalocker; we avoid new deps here.
+ """
+ lock_path = target.with_suffix(target.suffix + self._lock_suffix)
+ # Busy-wait a few short tries to avoid long stalls
+ import time
+ delay_s = 0.02
+ for _ in range(250): # ~5 seconds max
+ try:
+ # Exclusive creation
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ try:
+ with os.fdopen(fd, "w") as f:
+ f.write(str(os.getpid()))
+ break
+ except Exception:
+ os.close(fd)
+ raise
+ except FileExistsError:
+ time.sleep(delay_s)
+ else:
+ # Last resort: proceed without lock to avoid deadlock
+ fd = None
+
+ try:
+ yield
+ finally:
+ if lock_path.exists():
+ try:
+ os.remove(lock_path)
+ except Exception:
+ pass
+
+
diff --git a/src/mcpstore/core/configuration/standalone_config.py b/src/mcpstore/core/configuration/standalone_config.py
new file mode 100644
index 00000000..daef81c8
--- /dev/null
+++ b/src/mcpstore/core/configuration/standalone_config.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""
+MCPStore Standalone Configuration System
+Works completely independent of environment variables, through default parameters and initialization configuration
+"""
+
+import logging
+from copy import deepcopy
+from dataclasses import dataclass, field
+from typing import Dict, Any, Optional, Union
+
+from ..registry.schema_manager import get_schema_manager
+from ...config.config_defaults import StandaloneConfigDefaults
+
+logger = logging.getLogger(__name__)
+
+_standalone_defaults = StandaloneConfigDefaults()
+
+@dataclass
+class StandaloneConfig:
+ """Standalone configuration class - does not depend on any environment variables"""
+
+ # === Core configuration ===
+ heartbeat_interval_seconds: int = int(_standalone_defaults.heartbeat_interval_seconds)
+ http_timeout_seconds: int = int(_standalone_defaults.http_timeout_seconds)
+ reconnection_interval_seconds: int = int(_standalone_defaults.reconnection_interval_seconds)
+ cleanup_interval_seconds: int = int(_standalone_defaults.cleanup_interval_seconds)
+
+ # === Network configuration ===
+ streamable_http_endpoint: str = "/mcp"
+ default_transport: str = _standalone_defaults.default_transport
+
+ # === File path configuration ===
+ config_dir: Optional[str] = None # If None, use in-memory configuration
+ mcp_config_file: Optional[str] = None
+ # Single data source architecture: only support unified config
+
+ # === Service configuration ===
+ known_services: Dict[str, Dict[str, Any]] = field(default_factory=lambda: {})
+
+ # === Environment configuration removed ===
+ # Environment variable handling is now completely handled by FastMCP, no longer need these configurations
+
+ # === Logging configuration ===
+ log_level: str = _standalone_defaults.log_level
+ log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ enable_debug: bool = _standalone_defaults.enable_debug
+
+class StandaloneConfigManager:
+ """Standalone configuration manager - completely independent of environment variables"""
+
+ def __init__(self, config: Optional[StandaloneConfig] = None):
+ """
+ Initialize standalone configuration manager
+
+ Args:
+ config: Custom configuration, if None use default configuration
+ """
+ self.config = config or StandaloneConfig()
+ self._runtime_config: Dict[str, Any] = {}
+ self._service_configs: Dict[str, Dict[str, Any]] = {}
+
+ # Initialize default configuration
+ self._initialize_default_configs()
+
+ logger.info("StandaloneConfigManager initialized without environment dependencies")
+
+ def _initialize_default_configs(self):
+ """Initialize default configuration"""
+ # Set up runtime configuration
+ self._runtime_config = {
+ "timing": {
+ "heartbeat_interval_seconds": self.config.heartbeat_interval_seconds,
+ "http_timeout_seconds": self.config.http_timeout_seconds,
+ "reconnection_interval_seconds": self.config.reconnection_interval_seconds,
+ "cleanup_interval_seconds": self.config.cleanup_interval_seconds
+ },
+ "network": {
+ "streamable_http_endpoint": self.config.streamable_http_endpoint,
+ "default_transport": self.config.default_transport
+ },
+ "environment": {
+ "note": "Environment configuration removed - now handled by FastMCP"
+ }
+ }
+
+ # 使用Schema管理器初始化已知服务配置
+ schema_manager = get_schema_manager()
+ self._service_configs = {
+ "mcpstore-wiki": schema_manager.get_known_service_config("mcpstore-wiki"),
+ "howtocook": schema_manager.get_known_service_config("howtocook")
+ }
+ # 合并用户自定义的服务配置
+ self._service_configs.update(deepcopy(self.config.known_services))
+
+ def get_timing_config(self) -> Dict[str, int]:
+ """获取时间配置"""
+ return self._runtime_config["timing"]
+
+ def get_network_config(self) -> Dict[str, str]:
+ """获取网络配置"""
+ return self._runtime_config["network"]
+
+ def get_environment_config(self) -> Dict[str, Any]:
+ """获取环境配置"""
+ return self._runtime_config["environment"]
+
+ def get_service_config(self, service_name: str) -> Optional[Dict[str, Any]]:
+ """获取服务配置"""
+ return self._service_configs.get(service_name)
+
+ def add_service_config(self, service_name: str, config: Dict[str, Any]):
+ """添加服务配置"""
+ self._service_configs[service_name] = deepcopy(config)
+ logger.info(f"Added service config for: {service_name}")
+
+ def get_all_service_configs(self) -> Dict[str, Dict[str, Any]]:
+ """获取所有服务配置"""
+ return deepcopy(self._service_configs)
+
+ def get_mcp_config(self) -> Dict[str, Any]:
+ """获取MCP格式的配置"""
+ return {
+ "mcpServers": deepcopy(self._service_configs),
+ "version": "1.0.0",
+ "description": "MCPStore standalone configuration"
+ }
+
+ def update_config(self, **kwargs):
+ """更新配置"""
+ for key, value in kwargs.items():
+ if hasattr(self.config, key):
+ setattr(self.config, key, value)
+ logger.info(f"Updated config: {key} = {value}")
+
+ # 重新初始化配置
+ self._initialize_default_configs()
+
+ # get_isolated_environment方法已删除 - 环境变量处理现在完全由FastMCP处理
+
+ def get_config_paths(self) -> Dict[str, Optional[str]]:
+ """获取配置文件路径"""
+ return {
+ "config_dir": self.config.config_dir,
+ "mcp_config_file": self.config.mcp_config_file
+ }
+
+ def is_file_based(self) -> bool:
+ """检查是否使用文件配置"""
+ return self.config.config_dir is not None or self.config.mcp_config_file is not None
+
+class StandaloneConfigBuilder:
+ """独立配置构建器 - 提供流畅的配置构建接口"""
+
+ def __init__(self):
+ self._config = StandaloneConfig()
+
+ def with_timing(self, heartbeat: int = None, timeout: int = None, reconnection: int = None) -> 'StandaloneConfigBuilder':
+ """设置时间配置"""
+ if heartbeat is not None:
+ self._config.heartbeat_interval_seconds = heartbeat
+ if timeout is not None:
+ self._config.http_timeout_seconds = timeout
+ if reconnection is not None:
+ self._config.reconnection_interval_seconds = reconnection
+ return self
+
+ def with_network(self, endpoint: str = None, transport: str = None) -> 'StandaloneConfigBuilder':
+ """设置网络配置"""
+ if endpoint is not None:
+ self._config.streamable_http_endpoint = endpoint
+ if transport is not None:
+ self._config.default_transport = transport
+ return self
+
+ def with_files(self, config_dir: str = None, mcp_file: str = None) -> 'StandaloneConfigBuilder':
+ """设置文件配置"""
+ if config_dir is not None:
+ self._config.config_dir = config_dir
+ if mcp_file is not None:
+ self._config.mcp_config_file = mcp_file
+ return self
+
+ def with_service(self, name: str, config: Dict[str, Any]) -> 'StandaloneConfigBuilder':
+ """添加服务配置"""
+ self._config.known_services[name] = config
+ return self
+
+
+ def with_logging(self, level: str = None, debug: bool = None) -> 'StandaloneConfigBuilder':
+ """设置日志配置"""
+ if level is not None:
+ self._config.log_level = level
+ if debug is not None:
+ self._config.enable_debug = debug
+ return self
+
+ def build(self) -> StandaloneConfig:
+ """构建配置"""
+ return deepcopy(self._config)
+
+# === 预定义配置模板 ===
+
+def create_minimal_config() -> StandaloneConfig:
+ """创建最小配置 - 只包含基本功能"""
+ return StandaloneConfigBuilder().build()
+
+def create_development_config() -> StandaloneConfig:
+ """创建开发配置 - 包含调试功能"""
+ return (StandaloneConfigBuilder()
+ .with_timing(heartbeat=30, timeout=10, reconnection=60)
+ .with_logging(level="DEBUG", debug=True)
+ .build())
+
+# Removed preset configurations - MCPStore is just a tool, users decide their own configuration
+
+# === 全局配置实例 ===
+_global_config_manager: Optional[StandaloneConfigManager] = None
+
+def get_global_config() -> StandaloneConfigManager:
+ """获取全局配置管理器"""
+ global _global_config_manager
+ if _global_config_manager is None:
+ _global_config_manager = StandaloneConfigManager()
+ return _global_config_manager
+
+def set_global_config(config: Union[StandaloneConfig, StandaloneConfigManager]):
+ """设置全局配置"""
+ global _global_config_manager
+ if isinstance(config, StandaloneConfig):
+ _global_config_manager = StandaloneConfigManager(config)
+ else:
+ _global_config_manager = config
+ logger.info("Global standalone config updated")
+
+def reset_global_config():
+ """重置全局配置"""
+ global _global_config_manager
+ _global_config_manager = None
+ logger.info("Global standalone config reset")
+
diff --git a/src/mcpstore/core/configuration/unified_config.py b/src/mcpstore/core/configuration/unified_config.py
new file mode 100644
index 00000000..85d08118
--- /dev/null
+++ b/src/mcpstore/core/configuration/unified_config.py
@@ -0,0 +1,501 @@
+"""
+MCPStore Unified Configuration Manager
+
+Integrates all configuration functions, providing a unified configuration management interface.
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from enum import Enum
+from typing import Dict, Any, Optional, List
+
+# Import existing configuration components
+from mcpstore.config.config import load_app_config
+from mcpstore.config.json_config import MCPConfig, ConfigError
+from mcpstore.core.store.client_manager import ClientManager
+
+logger = logging.getLogger(__name__)
+
+class ConfigType(Enum):
+ """Configuration type enumeration"""
+ STANDALONE = "standalone" # Standalone/TOML 配置(来自 config.toml + MCPStoreConfig)
+ MCP_SERVICES = "mcp_services" # MCP服务配置
+ CLIENT_SERVICES = "client_services" # 客户端服务配置
+ AGENT_CLIENTS = "agent_clients" # Agent-Client映射配置
+
+@dataclass
+class ConfigInfo:
+ """Configuration information"""
+ config_type: ConfigType
+ source: str # Configuration source (file path or environment variable)
+ last_modified: Optional[str] = None
+ is_valid: bool = True
+ error_message: Optional[str] = None
+
+class UnifiedConfigManager:
+ """Unified configuration manager
+
+ Integrates all configuration functions including environment variables, MCP service configuration, client configuration, etc.
+ Provides unified configuration access, update, and validation interfaces.
+ """
+
+ def __init__(self,
+ mcp_config: Optional[MCPConfig] = None):
+ """Initialize unified configuration manager
+
+ Args:
+ mcp_config: MCPConfig instance (if None, creates default instance)
+ """
+ self.logger = logger
+
+ # 初始化各个配置组件
+ # standalone_config: 来自 config.toml + MCPStoreConfig 的全局非敏感配置
+ self.standalone_config = None
+ self.mcp_config = mcp_config if mcp_config is not None else MCPConfig()
+ self.client_manager = ClientManager()
+
+ # 配置缓存
+ self._config_cache: Dict[ConfigType, Dict[str, Any]] = {}
+ self._cache_valid: Dict[ConfigType, bool] = {}
+
+ # 并发保护锁(用于异步操作)
+ self._config_lock = asyncio.Lock()
+
+ # 初始化配置
+ self._initialize_configs()
+
+ logger.debug("UnifiedConfigManager initialized successfully")
+
+ def _initialize_configs(self):
+ """初始化所有配置"""
+ try:
+ # 加载 Standalone/TOML 配置(来自 config.toml + MCPStoreConfig)
+ self.standalone_config = load_app_config()
+ self._config_cache[ConfigType.STANDALONE] = self.standalone_config
+ self._cache_valid[ConfigType.STANDALONE] = True
+
+ # 预加载配置到缓存(单一数据源:仅加载 MCP_SERVICES;其余返回空映射)
+ self._refresh_cache(ConfigType.MCP_SERVICES)
+ self._refresh_cache(ConfigType.CLIENT_SERVICES)
+ self._refresh_cache(ConfigType.AGENT_CLIENTS)
+
+ except Exception as e:
+ logger.error(f"Failed to initialize configs: {e}")
+ raise ConfigError(f"Configuration initialization failed: {e}")
+
+ def _refresh_cache(self, config_type: ConfigType):
+ """刷新指定类型的配置缓存"""
+ try:
+ if config_type == ConfigType.MCP_SERVICES:
+ self._config_cache[config_type] = self.mcp_config.load_config()
+ self._cache_valid[config_type] = True
+ elif config_type in (ConfigType.CLIENT_SERVICES, ConfigType.AGENT_CLIENTS):
+ # 单一数据源架构:分片文件已废弃,统一返回空映射并标记为有效,避免异常
+ self._config_cache[config_type] = {}
+ self._cache_valid[config_type] = True
+ else:
+ self._cache_valid[config_type] = False
+
+ except Exception as e:
+ logger.error(f"Failed to refresh cache for {config_type}: {e}")
+ self._cache_valid[config_type] = False
+ raise
+
+ def get_config(self, config_type: ConfigType, force_reload: bool = False) -> Dict[str, Any]:
+ """获取指定类型的配置
+
+ Args:
+ config_type: 配置类型
+ force_reload: 是否强制重新加载
+
+ Returns:
+ 配置字典
+ """
+ if force_reload or not self._cache_valid.get(config_type, False):
+ if config_type == ConfigType.STANDALONE:
+ self.standalone_config = load_app_config()
+ self._config_cache[config_type] = self.standalone_config
+ else:
+ self._refresh_cache(config_type)
+
+ return self._config_cache.get(config_type, {})
+
+ def get_standalone_config(self) -> Dict[str, Any]:
+ """获取 Standalone/TOML 全局配置(来自 config.toml + MCPStoreConfig)"""
+ return self.get_config(ConfigType.STANDALONE)
+
+ def get_mcp_config(self) -> Dict[str, Any]:
+ """获取MCP服务配置"""
+ return self.get_config(ConfigType.MCP_SERVICES)
+
+ def get_client_config(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """获取指定客户端的配置
+
+ Args:
+ client_id: 客户端ID
+
+ Returns:
+ 客户端配置或None
+ """
+ client_configs = self.get_config(ConfigType.CLIENT_SERVICES)
+ return client_configs.get(client_id)
+
+ def get_agent_clients(self, agent_id: str) -> List[str]:
+ """获取指定Agent的客户端列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 客户端ID列表
+ """
+ agent_configs = self.get_config(ConfigType.AGENT_CLIENTS)
+ return agent_configs.get(agent_id, [])
+
+ def get_service_config(self, service_name: str) -> Optional[Dict[str, Any]]:
+ """获取指定服务的配置
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ 服务配置或None
+ """
+ return self.mcp_config.get_service_config(service_name)
+
+ def update_mcp_config(self, config: Dict[str, Any]) -> bool:
+ """更新MCP配置
+
+ Args:
+ config: 新的MCP配置
+
+ Returns:
+ 更新是否成功
+ """
+ try:
+ result = self.mcp_config.save_config(config)
+ if result:
+ self._refresh_cache(ConfigType.MCP_SERVICES)
+ return result
+ except Exception as e:
+ logger.error(f"Failed to update MCP config: {e}")
+ return False
+
+ def update_service_config(self, service_name: str, config: Dict[str, Any]) -> bool:
+ """更新服务配置
+
+ Args:
+ service_name: 服务名称
+ config: 服务配置
+
+ Returns:
+ 更新是否成功
+ """
+ try:
+ result = self.mcp_config.update_service(service_name, config)
+ if result:
+ self._refresh_cache(ConfigType.MCP_SERVICES)
+ return result
+ except Exception as e:
+ logger.error(f"Failed to update service config for {service_name}: {e}")
+ return False
+
+ def add_client(self, config: Dict[str, Any], client_id: Optional[str] = None) -> str:
+ """
+ 单一数据源架构:废弃方法,现已不支持
+
+ 新架构下,客户端配置通过mcp.json和缓存管理,不再单独管理
+ """
+ raise NotImplementedError(
+ "add_client已废弃。单一数据源架构下,请使用MCPStore.add_service()方法添加服务,"
+ "客户端配置将自动通过mcp.json和缓存管理。"
+ )
+
+ def get_all_configs(self) -> Dict[str, Dict[str, Any]]:
+ """获取所有配置
+
+ Returns:
+ 包含所有配置类型的字典
+ """
+ return {
+ "standalone": self.get_standalone_config(),
+ "mcp_services": self.get_mcp_config(),
+ "client_services": self.get_config(ConfigType.CLIENT_SERVICES),
+ "agent_clients": self.get_config(ConfigType.AGENT_CLIENTS),
+ }
+
+ def get_config_info(self) -> List[ConfigInfo]:
+ """获取所有配置的信息
+
+ Returns:
+ 配置信息列表
+ """
+ configs = []
+
+ # Standalone/TOML 配置信息
+ configs.append(ConfigInfo(
+ config_type=ConfigType.STANDALONE,
+ source="config.toml (Standalone/TOML)",
+ is_valid=self._cache_valid.get(ConfigType.STANDALONE, False),
+ ))
+
+ # MCP服务配置信息
+ configs.append(ConfigInfo(
+ config_type=ConfigType.MCP_SERVICES,
+ source=self.mcp_config.json_path,
+ is_valid=self._cache_valid.get(ConfigType.MCP_SERVICES, False)
+ ))
+
+ # 单一数据源架构:分片文件配置已废弃
+ configs.append(ConfigInfo(
+ config_type=ConfigType.CLIENT_SERVICES,
+ source="[已废弃] 单一数据源架构下不再使用分片文件",
+ is_valid=False,
+ error_message="单一数据源架构:client_services.json已废弃"
+ ))
+
+ configs.append(ConfigInfo(
+ config_type=ConfigType.AGENT_CLIENTS,
+ source="[已废弃] 单一数据源架构下不再使用分片文件",
+ is_valid=False,
+ error_message="单一数据源架构:agent_clients.json已废弃"
+ ))
+
+ return configs
+
+ def validate_all_configs(self) -> Dict[str, bool]:
+ """验证所有配置
+
+ Returns:
+ 各配置类型的验证结果
+ """
+ results = {}
+
+ try:
+ # 验证环境变量配置
+ env_config = self.get_env_config()
+ results["environment"] = isinstance(env_config, dict) and len(env_config) > 0
+ except Exception:
+ results["environment"] = False
+
+ try:
+ # 验证MCP配置
+ mcp_config = self.get_mcp_config()
+ results["mcp_services"] = "mcpServers" in mcp_config
+ except Exception:
+ results["mcp_services"] = False
+
+ try:
+ # 验证客户端配置
+ client_config = self.get_config(ConfigType.CLIENT_SERVICES)
+ results["client_services"] = isinstance(client_config, dict)
+ except Exception:
+ results["client_services"] = False
+
+ try:
+ # 验证Agent-Client映射
+ agent_config = self.get_config(ConfigType.AGENT_CLIENTS)
+ results["agent_clients"] = isinstance(agent_config, dict)
+ except Exception:
+ results["agent_clients"] = False
+
+ return results
+
+ def reload_all_configs(self):
+ """重新加载所有配置"""
+ logger.debug("Reloading all configurations...")
+
+ for config_type in ConfigType:
+ try:
+ self.get_config(config_type, force_reload=True)
+ logger.debug(f"Successfully reloaded {config_type.value} config")
+ except Exception as e:
+ logger.error(f"Failed to reload {config_type.value} config: {e}")
+
+ logger.debug("Configuration reload completed")
+
+ # ============ 新增便捷方法(方案B:统一配置管理)============
+
+ def add_service_config(self, service_name: str, config: Dict[str, Any]) -> bool:
+ """添加服务配置(语义化方法)
+
+ Args:
+ service_name: 服务名称
+ config: 服务配置
+
+ Returns:
+ bool: 添加是否成功
+ """
+ try:
+ # 强制从磁盘拉取最新配置,避免缓存滞后导致覆盖
+ current_config = self.get_config(ConfigType.MCP_SERVICES, force_reload=True)
+
+ # 确保 mcpServers 存在
+ if "mcpServers" not in current_config:
+ current_config["mcpServers"] = {}
+
+ # 添加服务配置
+ current_config["mcpServers"][service_name] = config
+
+ # 保存并自动刷新缓存
+ result = self.update_mcp_config(current_config)
+
+ if result:
+ logger.debug(f" Service '{service_name}' config added, cache synchronized")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Failed to add service config for {service_name}: {e}")
+ return False
+
+ def remove_service_config(self, service_name: str) -> bool:
+ """删除服务配置
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ bool: 删除是否成功
+ """
+ try:
+ # 删除前强制刷新,避免使用过期缓存
+ current_config = self.get_config(ConfigType.MCP_SERVICES, force_reload=True)
+
+ # 如果服务存在,则删除
+ if service_name in current_config.get("mcpServers", {}):
+ del current_config["mcpServers"][service_name]
+
+ # 保存并自动刷新缓存
+ result = self.update_mcp_config(current_config)
+
+ if result:
+ logger.debug(f" Service '{service_name}' config removed, cache synchronized")
+
+ return result
+ else:
+ # 服务不存在,视为成功(幂等性)
+ logger.debug(f"Service '{service_name}' does not exist, no need to delete")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to remove service config for {service_name}: {e}")
+ return False
+
+ def batch_add_services(self, services: Dict[str, Dict[str, Any]]) -> bool:
+ """批量添加服务配置(原子操作,一次性保存)
+
+ Args:
+ services: 服务配置字典 {service_name: service_config}
+
+ Returns:
+ bool: 批量添加是否成功
+ """
+ try:
+ if not services:
+ logger.debug("Batch add services: service list is empty, no operation needed")
+ return True
+
+ current_config = self.get_mcp_config()
+
+ # 确保 mcpServers 存在
+ if "mcpServers" not in current_config:
+ current_config["mcpServers"] = {}
+
+ # 批量合并服务配置
+ for service_name, service_config in services.items():
+ current_config["mcpServers"][service_name] = service_config
+
+ # 一次性保存(原子操作)并自动刷新缓存
+ result = self.update_mcp_config(current_config)
+
+ if result:
+ logger.debug(f" Batch added {len(services)} services successfully, cache synchronized")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Failed to batch add services: {e}")
+ return False
+
+ def batch_remove_services(self, service_names: List[str]) -> bool:
+ """批量删除服务配置(原子操作,一次性保存)
+
+ Args:
+ service_names: 服务名称列表
+
+ Returns:
+ bool: 批量删除是否成功
+ """
+ try:
+ if not service_names:
+ logger.debug("Batch remove services: service list is empty, no operation needed")
+ return True
+
+ current_config = self.get_mcp_config()
+ servers = current_config.get("mcpServers", {})
+
+ # 批量删除服务
+ removed_count = 0
+ for service_name in service_names:
+ if service_name in servers:
+ del servers[service_name]
+ removed_count += 1
+
+ # 一次性保存(原子操作)并自动刷新缓存
+ result = self.update_mcp_config(current_config)
+
+ if result:
+ logger.debug(f" Batch removed {removed_count}/{len(service_names)} services successfully, cache synchronized")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Failed to batch remove services: {e}")
+ return False
+
+ async def update_mcp_config_async(self, config: Dict[str, Any]) -> bool:
+ """更新MCP配置(异步版本,带并发保护)
+
+ Args:
+ config: 新的MCP配置
+
+ Returns:
+ bool: 更新是否成功
+ """
+ async with self._config_lock:
+ try:
+ result = self.mcp_config.save_config(config)
+ if result:
+ self._refresh_cache(ConfigType.MCP_SERVICES)
+ logger.debug(" MCP config updated (async), cache synchronized")
+ return result
+ except Exception as e:
+ logger.error(f"Failed to update MCP config (async): {e}")
+ return False
+
+ async def add_service_config_async(self, service_name: str, config: Dict[str, Any]) -> bool:
+ """添加服务配置(异步版本,带并发保护)
+
+ Args:
+ service_name: 服务名称
+ config: 服务配置
+
+ Returns:
+ bool: 添加是否成功
+ """
+ async with self._config_lock:
+ return self.add_service_config(service_name, config)
+
+ async def batch_add_services_async(self, services: Dict[str, Dict[str, Any]]) -> bool:
+ """批量添加服务配置(异步版本,带并发保护)
+
+ Args:
+ services: 服务配置字典
+
+ Returns:
+ bool: 批量添加是否成功
+ """
+ async with self._config_lock:
+ return self.batch_add_services(services)
diff --git a/src/mcpstore/core/context.py b/src/mcpstore/core/context.py
deleted file mode 100644
index f08774aa..00000000
--- a/src/mcpstore/core/context.py
+++ /dev/null
@@ -1,395 +0,0 @@
-"""
-MCPStore Context Module
-提供 MCPStore 的上下文管理功能
-"""
-
-from typing import Dict, List, Optional, Any, Union
-from dataclasses import dataclass
-from enum import Enum
-from mcpstore.core.models.tool import ToolExecutionRequest, ToolExecutionResponse
-from mcpstore.core.models.service import (
- ServiceInfo, AddServiceRequest, ServiceConfigUnion,
- URLServiceConfig, CommandServiceConfig, MCPServerConfig
-)
-import logging
-from .exceptions import ServiceNotFoundError, InvalidConfigError, DeleteServiceError
-
-@dataclass
-class ServiceInfo:
- """服务信息"""
- name: str
- status: str
- description: str
- tools: List[str]
-
-@dataclass
-class ToolInfo:
- """工具信息"""
- name: str
- description: str
- parameters: Dict[str, Any]
-
-class ContextType(Enum):
- """上下文类型"""
- STORE = "store"
- AGENT = "agent"
-
-class MCPStoreContext:
- """
- MCPStore上下文类
- 负责处理具体的业务操作,维护操作的上下文环境
- """
- def __init__(self, store: 'MCPStore', agent_id: Optional[str] = None):
- self._store = store
- self._agent_id = agent_id
- self._context_type = ContextType.STORE if agent_id is None else ContextType.AGENT
-
- # 扩展预留
- self._metadata: Dict[str, Any] = {}
- self._config: Dict[str, Any] = {}
- self._cache: Dict[str, Any] = {}
-
- # === 核心服务接口 ===
- async def list_services(self) -> List[ServiceInfo]:
- """
- 列出服务列表
- - store上下文:聚合 main_client 下所有 client_id 的服务
- - agent上下文:聚合 agent_id 下所有 client_id 的服务
- """
- if self._context_type == ContextType.STORE:
- return await self._store.list_services()
- else:
- return await self._store.list_services(self._agent_id, agent_mode=True)
-
- async def add_service(self, config: Union[ServiceConfigUnion, List[str], None] = None) -> bool:
- """
- 增强版的服务添加方法,支持多种配置格式:
- 1. URL方式:
- await add_service({
- "name": "weather",
- "url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
- })
-
- 2. 本地命令方式:
- await add_service({
- "name": "assistant",
- "command": "python",
- "args": ["./assistant_server.py"],
- "env": {"DEBUG": "true"}
- })
-
- 3. MCPConfig字典方式:
- await add_service({
- "mcpServers": {
- "weather": {
- "url": "https://weather-api.example.com/mcp"
- }
- }
- })
-
- 4. 服务名称列表方式(从现有配置中选择):
- await add_service(['weather', 'assistant'])
-
- 5. 无参数方式(仅限Store上下文):
- await add_service() # 注册所有服务
-
- 所有新添加的服务都会同步到 mcp.json 配置文件中。
-
- Args:
- config: 服务配置,支持多种格式
-
- Returns:
- bool: 是否成功添加服务
- """
- try:
- # 获取正确的 client_id
- client_id = self._agent_id if self._context_type == ContextType.AGENT else self._store.client_manager.main_client_id
- print(f"[INFO][add_service] 当前模式: {self._context_type.name}, client_id: {client_id}")
-
- # 处理不同的输入格式
- if config is None:
- # Store模式下的全量注册
- if self._context_type == ContextType.STORE:
- print("[INFO][add_service] STORE模式-全量注册所有服务")
- resp = await self._store.register_json_service()
- print(f"[INFO][add_service] 注册结果: {resp}")
- return bool(resp and resp.service_names)
- else:
- print("[WARN][add_service] AGENT模式-未指定服务配置")
- return False
-
- # 处理服务名称列表
- if isinstance(config, list):
- if not config:
- print("[WARN][add_service] 服务名称列表为空")
- return False
-
- print(f"[INFO][add_service] 注册指定服务: {config}")
- resp = await self._store.register_json_service(
- client_id=client_id,
- service_names=config
- )
- print(f"[INFO][add_service] 注册结果: {resp}")
- return bool(resp and resp.service_names)
-
- # 处理字典格式的配置
- if isinstance(config, dict):
- # 转换为标准格式
- if "mcpServers" in config:
- # 已经是MCPConfig格式
- mcp_config = config
- else:
- # 单个服务配置,需要转换为MCPConfig格式
- service_name = config.get("name")
- if not service_name:
- print("[ERROR][add_service] 服务配置缺少name字段")
- return False
-
- mcp_config = {
- "mcpServers": {
- service_name: {k: v for k, v in config.items() if k != "name"}
- }
- }
-
- # 更新配置文件
- try:
- # 1. 加载现有配置
- current_config = self._store.config.load_config()
-
- # 2. 合并新配置
- for name, service_config in mcp_config["mcpServers"].items():
- current_config["mcpServers"][name] = service_config
-
- # 3. 保存更新后的配置
- self._store.config.save_config(current_config)
-
- # 4. 重新加载配置以确保同步
- self._store.config.load_config()
-
- # 5. 注册服务
- service_names = list(mcp_config["mcpServers"].keys())
- print(f"[INFO][add_service] 注册服务: {service_names}")
- resp = await self._store.register_json_service(
- client_id=client_id,
- service_names=service_names
- )
- print(f"[INFO][add_service] 注册结果: {resp}")
- return bool(resp and resp.service_names)
-
- except Exception as e:
- print(f"[ERROR][add_service] 更新配置文件失败: {e}")
- return False
-
- print(f"[ERROR][add_service] 不支持的配置格式: {type(config)}")
- return False
-
- except Exception as e:
- print(f"[ERROR][add_service] 服务添加失败: {e}")
- return False
-
- async def list_tools(self) -> List[ToolInfo]:
- """
- 列出工具列表
- - store上下文:聚合 main_client 下所有 client_id 的工具
- - agent上下文:聚合 agent_id 下所有 client_id 的工具
- """
- if self._context_type == ContextType.STORE:
- return await self._store.list_tools()
- else:
- return await self._store.list_tools(self._agent_id, agent_mode=True)
-
- async def check_services(self) -> dict:
- """
- 异步健康检查,store/agent上下文自动判断
- - store上下文:聚合 main_client 下所有 client_id 的服务健康状态
- - agent上下文:聚合 agent_id 下所有 client_id 的服务健康状态
- """
- if self._context_type.name == 'STORE':
- return await self._store.get_health_status()
- elif self._context_type.name == 'AGENT':
- return await self._store.get_health_status(self._agent_id, agent_mode=True)
- else:
- print(f"[ERROR][check_services] 未知上下文类型: {self._context_type}")
- return {}
-
- async def get_service_info(self, name: str) -> Any:
- """
- 获取服务详情,支持 store/agent 上下文
- - store上下文:在 main_client 下的所有 client 中查找服务
- - agent上下文:在指定 agent_id 下的所有 client 中查找服务
- """
- if not name:
- return {}
-
- if self._context_type == ContextType.STORE:
- print(f"[INFO][get_service_info] STORE模式-在main_client中查找服务: {name}")
- return await self._store.get_service_info(name)
- elif self._context_type == ContextType.AGENT:
- print(f"[INFO][get_service_info] AGENT模式-在agent({self._agent_id})中查找服务: {name}")
- return await self._store.get_service_info(name, self._agent_id)
- else:
- print(f"[ERROR][get_service_info] 未知上下文类型: {self._context_type}")
- return {}
-
- async def use_tool(self, tool_name: str, args: Dict[str, Any]) -> Any:
- """
- 使用工具,支持 store/agent 上下文
- - store上下文:在 main_client 下的所有 client 中查找并使用工具
- - agent上下文:在指定 agent_id 下的所有 client 中查找并使用工具
-
- Args:
- tool_name: 工具名称,格式为 service_toolname
- args: 工具参数
-
- Returns:
- Any: 工具执行结果
- """
- # 从工具名称中提取服务名称
- if "_" not in tool_name:
- raise ValueError(f"Invalid tool name format: {tool_name}. Expected format: service_toolname")
-
- if self._context_type == ContextType.STORE:
- print(f"[INFO][use_tool] STORE模式-在main_client中使用工具: {tool_name}")
- request = ToolExecutionRequest(
- tool_name=tool_name,
- args=args
- )
- else:
- print(f"[INFO][use_tool] AGENT模式-在agent({self._agent_id})中使用工具: {tool_name}")
- request = ToolExecutionRequest(
- tool_name=tool_name,
- args=args,
- agent_id=self._agent_id
- )
-
- return await self._store.process_tool_request(request)
-
- # === 上下文信息 ===
- @property
- def context_type(self) -> ContextType:
- """获取上下文类型"""
- return self._context_type
-
- @property
- def agent_id(self) -> Optional[str]:
- """获取当前agent_id"""
- return self._agent_id
-
- def show_mcpconfig(self) -> Dict[str, Any]:
- """
- 根据当前上下文(store/agent)获取对应的配置信息
-
- Returns:
- Dict[str, Any]: 包含所有相关client配置的字典
- """
- # 获取所有相关的client_ids
- client_ids = self._store.orchestrator.client_manager.get_agent_clients(
- self._agent_id if self._context_type == ContextType.AGENT else self._store.orchestrator.client_manager.main_client_id
- )
-
- # 获取每个client的配置
- result = {}
- for client_id in client_ids:
- client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
- if client_config:
- result[client_id] = client_config
-
- return result
-
- async def update_service(self, name: str, config: Dict[str, Any]) -> bool:
- """
- 更新服务配置
-
- Args:
- name: 服务名称(不可更改)
- config: 新的服务配置
-
- Returns:
- bool: 更新是否成功
-
- Raises:
- ServiceNotFoundError: 服务不存在
- InvalidConfigError: 配置无效
- """
- try:
- # 1. 验证服务是否存在
- if not self._store.config.get_service_config(name):
- raise ServiceNotFoundError(f"Service {name} not found")
-
- # 2. 更新 mcp.json 中的配置(无论是 store 还是 agent 级别都要更新)
- if not self._store.config.update_service(name, config):
- raise InvalidConfigError(f"Failed to update service {name}")
-
- # 3. 获取需要更新的 client_ids
- if self._context_type == ContextType.STORE:
- # store 级别:更新所有 client
- client_ids = self._store.orchestrator.client_manager.get_main_client_ids()
- else:
- # agent 级别:同样更新所有配置
- client_ids = self._store.orchestrator.client_manager.get_main_client_ids()
-
- # 4. 更新每个 client 的配置
- for client_id in client_ids:
- client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
- if client_config and name in client_config.get("mcpServers", {}):
- client_config["mcpServers"][name] = config
- self._store.orchestrator.client_manager.save_client_config(client_id, client_config)
-
- return True
-
- except Exception as e:
- logging.error(f"Failed to update service {name}: {str(e)}")
- raise
-
- async def delete_service(self, name: str) -> bool:
- """
- 删除服务
-
- Args:
- name: 要删除的服务名称
-
- Returns:
- bool: 删除是否成功
-
- Raises:
- ServiceNotFoundError: 服务不存在
- DeleteServiceError: 删除失败
- """
- try:
- # 1. 验证服务是否存在
- if not self._store.config.get_service_config(name):
- raise ServiceNotFoundError(f"Service {name} not found")
-
- # 2. 根据上下文确定删除范围
- if self._context_type == ContextType.STORE:
- # store 级别:删除所有 client 中的服务并更新 mcp.json
- client_ids = self._store.orchestrator.client_manager.get_main_client_ids()
-
- # 从 mcp.json 中删除
- if not self._store.config.remove_service(name):
- raise DeleteServiceError(f"Failed to remove service {name} from mcp.json")
-
- # 从所有 client 配置中删除
- for client_id in client_ids:
- client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
- if client_config and name in client_config.get("mcpServers", {}):
- del client_config["mcpServers"][name]
- self._store.orchestrator.client_manager.save_client_config(client_id, client_config)
-
- else:
- # agent 级别:只删除该 agent 的 client 列表中的服务
- client_ids = self._store.orchestrator.client_manager.get_agent_clients(self._agent_id)
-
- # 从指定 agent 的 client 配置中删除
- for client_id in client_ids:
- client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
- if client_config and name in client_config.get("mcpServers", {}):
- del client_config["mcpServers"][name]
- self._store.orchestrator.client_manager.save_client_config(client_id, client_config)
-
- return True
-
- except Exception as e:
- logging.error(f"Failed to delete service {name}: {str(e)}")
- raise
diff --git a/src/mcpstore/core/context/__init__.py b/src/mcpstore/core/context/__init__.py
new file mode 100644
index 00000000..0963705c
--- /dev/null
+++ b/src/mcpstore/core/context/__init__.py
@@ -0,0 +1,53 @@
+"""
+MCPStore Context Package
+Refactored context management module
+
+This package splits the original large context.py file into multiple specialized modules:
+- base_context: Core context class and basic functionality
+- service_operations: Service-related operations
+- tool_operations: Tool-related operations
+- service_proxy: Service proxy object for specific service operations
+- tool_proxy: Tool proxy object for specific tool operations
+- tool_transformation: Tool transformation and enhancement functionality
+- agent_service_mapper: Agent service name mapping functionality
+- resources_prompts: Resources and Prompts functionality
+- advanced_features: Advanced features
+"""
+
+from .agent_service_mapper import AgentServiceMapper
+from .base_context import MCPStoreContext
+from .cache_proxy import CacheProxy
+from .service_management import UpdateServiceAuthHelper
+from .service_proxy import ServiceProxy
+from .session import Session, SessionContext
+from .session_management import SessionManagementMixin
+from .tool_proxy import ToolProxy, ToolCallResult
+from .tool_transformation import (
+ ToolTransformer,
+ ToolTransformationManager,
+ ToolTransformConfig,
+ ArgumentTransform,
+ TransformationType,
+ get_transformation_manager
+)
+from .types import ContextType
+
+__all__ = [
+ 'ContextType',
+ 'MCPStoreContext',
+ 'ServiceProxy',
+ 'ToolProxy',
+ 'ToolCallResult',
+ 'AgentServiceMapper',
+ 'UpdateServiceAuthHelper',
+ 'Session',
+ 'SessionContext',
+ 'SessionManagementMixin',
+ 'ToolTransformer',
+ 'ToolTransformationManager',
+ 'ToolTransformConfig',
+ 'ArgumentTransform',
+ 'TransformationType',
+ 'get_transformation_manager',
+ 'CacheProxy'
+]
diff --git a/src/mcpstore/core/context/advanced_features.py b/src/mcpstore/core/context/advanced_features.py
new file mode 100644
index 00000000..27873367
--- /dev/null
+++ b/src/mcpstore/core/context/advanced_features.py
@@ -0,0 +1,160 @@
+"""
+MCPStore Advanced Features Module
+Implementation of advanced feature-related operations
+"""
+
+import logging
+from typing import Optional, Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .base_context import MCPStoreContext
+
+logger = logging.getLogger(__name__)
+
+class AdvancedFeaturesMixin:
+ """Advanced features mixin class"""
+
+ def import_api(self, api_url: str, api_name: str = None) -> 'MCPStoreContext':
+ """
+ 导入 OpenAPI 服务(同步)
+
+ Args:
+ api_url: API 规范 URL
+ api_name: API 名称(可选)
+
+ Returns:
+ MCPStoreContext: 支持链式调用
+ """
+ return self._run_async_via_bridge(
+ self.import_api_async(api_url, api_name),
+ op_name="advanced_features.import_api"
+ )
+
+ async def import_api_async(self, api_url: str, api_name: str = None) -> 'MCPStoreContext':
+ """
+ 导入 OpenAPI 服务(异步)
+
+ Args:
+ api_url: API 规范 URL
+ api_name: API 名称(可选)
+
+ Returns:
+ MCPStoreContext: 支持链式调用
+ """
+ try:
+ import time
+ api_name = api_name or f"api_{int(time.time())}"
+ result = await self._openapi_manager.import_openapi_service(
+ name=api_name,
+ spec_url=api_url
+ )
+ logger.info(f"[{self._context_type.value}] Imported API {api_name}: {result.get('total_endpoints', 0)} endpoints")
+ return self
+ except Exception as e:
+ logger.error(f"[{self._context_type.value}] Failed to import API {api_url}: {e}")
+ return self
+
+
+ def reset_mcp_json_file(self) -> bool:
+ """重置MCP JSON配置文件(同步版本)- 缓存优先模式"""
+ return self._run_async_via_bridge(
+ self.reset_mcp_json_file_async(),
+ op_name="advanced_features.reset_mcp_json_file",
+ timeout=60.0
+ )
+
+ async def reset_mcp_json_file_async(self, scope: str = "all") -> bool:
+ """
+ 重置MCP JSON配置文件(异步版本)- 单一数据源架构
+
+ Args:
+ scope: 重置范围
+ - "all": 重置整个mcp.json(清空所有服务)
+ - "global_agent_store": 只清空Store级别的服务,保留Agent服务
+ - agent_id: 只清空指定Agent的服务
+
+ 新架构逻辑:
+ 1. 根据scope确定要清理的缓存范围
+ 2. 同步更新mcp.json文件
+ 3. 触发缓存重新同步(可选)
+ """
+ try:
+ logger.info(f" [MCP_RESET] Starting MCP JSON file reset with scope: {scope}")
+
+ # 使用 UnifiedConfigManager 读取配置(从缓存)
+ current_config = self._store._unified_config.get_mcp_config()
+ mcp_servers = current_config.get("mcpServers", {})
+
+ if scope == "all":
+ # 重置整个mcp.json
+ logger.info(" [MCP_RESET] Clearing all services from mcp.json")
+
+ # 1. 清空所有缓存(通过Registry异步API)
+ try:
+ agent_ids = await self._store.registry.get_all_agent_ids_async()
+ except Exception:
+ agent_ids = []
+ for agent_id in agent_ids:
+ try:
+ await self._store.registry.clear_async(agent_id)
+ except Exception as e:
+ logger.warning(f"Failed to clear agent {agent_id}: {e}")
+
+ # 2. 重置mcp.json为空
+ new_config = {"mcpServers": {}}
+
+ elif scope == "global_agent_store":
+ # 只清空Store级别的服务,保留Agent服务
+ logger.info(" [MCP_RESET] Clearing Store services, preserving Agent services")
+
+ # 1. 清空global_agent_store缓存(使用异步版本)
+ global_agent_store_id = self._store.client_manager.global_agent_store_id
+ await self._store.registry.clear_async(global_agent_store_id)
+
+ # 2. 从mcp.json中移除非Agent服务(不带@后缀的服务)
+ preserved_services = {}
+ for service_name, service_config in mcp_servers.items():
+ if "@" in service_name: # Agent服务(带@agent_id后缀)
+ preserved_services[service_name] = service_config
+
+ new_config = {"mcpServers": preserved_services}
+ logger.info(f" [MCP_RESET] Preserved {len(preserved_services)} Agent services")
+
+ else:
+ # 清空指定Agent的服务
+ agent_id = scope
+ logger.info(f" [MCP_RESET] Clearing services for Agent: {agent_id}")
+
+ # 1. 清空该Agent的缓存(使用异步版本)
+ await self._store.registry.clear_async(agent_id)
+
+ # 2. 从mcp.json中移除该Agent的服务
+ preserved_services = {}
+ agent_suffix = f"@{agent_id}"
+
+ for service_name, service_config in mcp_servers.items():
+ if not service_name.endswith(agent_suffix):
+ preserved_services[service_name] = service_config
+
+ new_config = {"mcpServers": preserved_services}
+ removed_count = len(mcp_servers) - len(preserved_services)
+ logger.info(f" [MCP_RESET] Removed {removed_count} services for Agent {agent_id}")
+
+ # 3. 保存更新后的mcp.json(使用 UnifiedConfigManager 自动刷新缓存)
+ mcp_success = self._store._unified_config.update_mcp_config(new_config)
+
+ if mcp_success:
+ logger.info(f"[MCP_RESET] [COMPLETE] MCP JSON file reset completed for scope: {scope}, cache synchronized")
+
+ # 4. 触发重新同步(可选)
+ if hasattr(self._store.orchestrator, 'sync_manager') and self._store.orchestrator.sync_manager:
+ logger.info(" [MCP_RESET] Triggering cache resync from mcp.json")
+ await self._store.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+ else:
+ logger.error(f" [MCP_RESET] Failed to save mcp.json for scope: {scope}")
+
+ return mcp_success
+
+ except Exception as e:
+ logger.error(f" [MCP_RESET] Failed to reset MCP JSON file with scope {scope}: {e}")
+ return False
diff --git a/src/mcpstore/core/context/agent_proxy.py b/src/mcpstore/core/context/agent_proxy.py
new file mode 100644
index 00000000..f2b2a5c7
--- /dev/null
+++ b/src/mcpstore/core/context/agent_proxy.py
@@ -0,0 +1,655 @@
+"""
+AgentProxy - objectified agent-view proxy.
+Lightweight, stateless handle bound to a specific agent_id.
+Delegates to existing context/mixins/registry for all operations.
+"""
+
+import logging
+from typing import Any, Dict, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .base_context import MCPStoreContext
+ from .service_proxy import ServiceProxy
+ from .tool_proxy import ToolProxy
+
+logger = logging.getLogger(__name__)
+
+
+class AgentProxy:
+ """
+ Proxy object for agent-specific operations.
+
+ Provides a unified interface for managing agent-level services, tools,
+ and operations with proper context isolation and caching.
+ """
+
+ def __init__(self, context: "MCPStoreContext", agent_id: str):
+ """
+ Initialize AgentProxy with context and agent identifier.
+
+ Args:
+ context: The MCPStoreContext instance for operations
+ agent_id: Unique identifier for this agent
+ """
+ self._context = context
+ self._agent_id = agent_id
+ # Use the provided context directly instead of creating a duplicate
+ self._agent_ctx = context
+
+ # ---- Identity ----
+ def get_id(self) -> str:
+ return self._agent_id
+
+ # ---- Info & stats ----
+ def get_info(self) -> Dict[str, Any]:
+ # Compose a lightweight info dict; metadata fields may be None
+ return {
+ "agent_id": self._agent_id,
+ "name": None,
+ "description": None,
+ "created_at": None,
+ "last_active": None,
+ "metadata": None,
+ }
+
+ def get_stats(self) -> Dict[str, Any]:
+ raise RuntimeError("[AGENT_PROXY] Synchronous get_stats is disabled, please use get_stats_async.")
+
+ async def get_stats_async(self) -> Dict[str, Any]:
+ """异步获取 Agent 统计,供异步场景和 FastAPI 使用。"""
+ try:
+ stats = await self._context._get_agent_statistics(self._agent_id)
+ if hasattr(stats, "__dict__"):
+ d = dict(stats.__dict__)
+ services = d.get("services", [])
+ d["services"] = [s.__dict__ if hasattr(s, "__dict__") else s for s in services]
+ return d
+ return stats
+ except Exception:
+ return {
+ "agent_id": self._agent_id,
+ "service_count": 0,
+ "tool_count": 0,
+ "healthy_services": 0,
+ "unhealthy_services": 0,
+ "total_tool_executions": 0,
+ "is_active": False,
+ "last_activity": None,
+ "services": [],
+ }
+
+ def find_cache(self) -> "CacheProxy":
+ from .cache_proxy import CacheProxy
+ return CacheProxy(self._context, scope="agent", scope_value=self._agent_id)
+
+ # ---- Services & tools ----
+ def list_services(self):
+ """
+ 列出 Agent 视角的服务,直接返回 ServiceInfo 列表
+ """
+ ctx = self._agent_ctx or self._context
+ return ctx.list_services()
+
+ def find_service(self, name: str) -> "ServiceProxy":
+ """
+ 查找服务并返回服务代理对象
+
+ 验证服务归属于当前 Agent
+
+ Args:
+ name: 服务名称(本地名称)
+
+ Returns:
+ ServiceProxy: 绑定到当前 Agent 的服务代理对象
+
+ Raises:
+ ServiceNotFoundException: 服务不存在
+ ServiceBindingError: 服务不属于当前 Agent
+
+ Validates: Requirements 6.6, 6.7 (服务归属验证)
+ """
+ from .service_proxy import ServiceProxy
+ from mcpstore.core.exceptions import ServiceNotFoundException, ServiceBindingError
+
+ ctx = self._agent_ctx or self._context
+
+ # 验证服务归属
+ try:
+ verified, global_name = self._verify_service_ownership(name)
+ if not verified:
+ raise ServiceBindingError(
+ service_name=name,
+ agent_id=self._agent_id,
+ reason="服务不属于当前 Agent"
+ )
+
+ # 创建 ServiceProxy 时传入 agent_id 和 global_name
+ return ServiceProxy(
+ ctx,
+ name,
+ agent_id=self._agent_id,
+ global_name=global_name
+ )
+ except ServiceNotFoundException:
+ raise
+ except ServiceBindingError:
+ raise
+ except Exception as e:
+ logger.error(f"[AGENT_PROXY] Failed to find service '{name}': {e}")
+ raise ServiceNotFoundException(service_name=name, agent_id=self._agent_id)
+
+ def _verify_service_ownership(self, service_name: str) -> tuple[bool, str]:
+ """
+ 验证服务归属于当前 Agent
+
+ Args:
+ service_name: 服务名称(本地名称)
+
+ Returns:
+ tuple[bool, str]: (是否验证通过, 全局服务名称)
+
+ Raises:
+ ServiceNotFoundException: 服务不存在
+
+ Validates: Requirements 6.6, 6.7 (服务归属验证)
+ """
+ from mcpstore.core.exceptions import ServiceNotFoundException
+
+ ctx = self._agent_ctx or self._context
+
+ # 通过 Registry 验证服务映射
+ try:
+ # 使用 Registry 获取服务的全局名称
+ global_name = ctx._store.registry.get_global_name_from_agent_service(
+ self._agent_id,
+ service_name
+ )
+
+ if not global_name:
+ raise ServiceNotFoundException(
+ service_name=service_name,
+ agent_id=self._agent_id
+ )
+
+ logger.debug(f"[AGENT_PROXY] Verified ownership of service '{service_name}' for agent '{self._agent_id}'")
+ return True, global_name
+
+ except ServiceNotFoundException:
+ raise
+ except Exception as e:
+ logger.error(f"[AGENT_PROXY] Failed to verify service ownership: {e}")
+ raise ServiceNotFoundException(
+ service_name=service_name,
+ agent_id=self._agent_id
+ )
+
+ def list_tools(
+ self,
+ service_name: str = None,
+ *,
+ filter: str = "available"
+ ):
+ """
+ 列出工具
+
+ Args:
+ service_name: 服务名称(可选)
+ filter: 筛选范围 ("available" 或 "all")
+
+ Returns:
+ 工具列表(ToolInfo 对象列表)
+ """
+ ctx = self._agent_ctx or self._context
+ return ctx.list_tools(service_name=service_name, filter=filter)
+
+ # ---- Health & runtime ----
+ def check_services(self) -> Dict[str, Any]:
+ raise RuntimeError("[AGENT_PROXY] Synchronous check_services is disabled, please use check_services_async.")
+
+ def call_tool(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
+ # 为兼容同步示例,桥接到异步实现;若当前线程已有事件循环,会抛出异常提示使用异步
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ if loop.is_running():
+ raise RuntimeError("[AGENT_PROXY] Current thread already has an event loop, please use call_tool_async.")
+ except RuntimeError:
+ pass # 无运行中的 loop,可安全使用 asyncio.run
+
+ return asyncio.run(self.call_tool_async(tool_name, args))
+
+ # ---- Mutations ----
+ def add_service(self, config: Dict[str, Any]) -> bool:
+ """
+ 同步添加服务(仅在当前线程不存在事件循环时使用)。
+
+ - 如果当前线程已有事件循环,会提醒使用 add_service_async 以避免 AOB 冲突。
+ - 在普通同步脚本中,可直接调用;内部通过 asyncio.run 执行异步逻辑。
+ """
+ return self.add_service_blocking(config)
+
+ def add_service_blocking(self, *args, **kwargs) -> bool:
+ """
+ 便捷同步包装:在当前线程没有事件循环时,阻塞调用 add_service_async。
+ 若线程已有事件循环,仍需显式使用 add_service_async 以避免死锁。
+ """
+ import asyncio
+
+ try:
+ loop = asyncio.get_running_loop()
+ if loop.is_running():
+ raise RuntimeError("[AGENT_PROXY] Current thread already has an event loop, please use add_service_async.")
+ except RuntimeError:
+ # 没有运行中的事件循环,安全使用 asyncio.run
+ return asyncio.run(self.add_service_async(*args, **kwargs))
+
+ # 理论上不会走到这里
+ return False
+
+ def update_service(self, name: str, patch: Dict[str, Any]) -> bool:
+ raise RuntimeError("[AGENT_PROXY] Synchronous update_service is disabled, please use update_service_async.")
+
+ def delete_service(self, name: str) -> bool:
+ raise RuntimeError("[AGENT_PROXY] Synchronous delete_service is disabled, please use delete_service_async.")
+
+ # Async counterparts (explicit wrappers)
+ async def add_service_async(self, *args, **kwargs):
+ ctx = self._agent_ctx or self._context
+ return await ctx.add_service_async(*args, **kwargs)
+
+ async def call_tool_async(self, tool_name: str, args: Dict[str, Any]):
+ ctx = self._agent_ctx or self._context
+ return await ctx.call_tool_async(tool_name, args)
+
+ async def show_config_async(self) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return await ctx.show_config_async()
+
+ async def delete_config_async(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return await ctx.delete_config_async(client_id_or_service_name)
+
+ async def update_config_async(self, client_id_or_service_name: str, new_config: Dict[str, Any]) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return await ctx.update_config_async(client_id_or_service_name, new_config)
+
+ async def reset_config_async(self) -> bool:
+ ctx = self._agent_ctx or self._context
+ return await ctx.reset_config_async()
+
+ async def get_tool_records_async(self, limit: int = 50) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return await ctx.get_tool_records_async(limit)
+
+ # ---- Service info/status & extended ops ----
+ def get_service_info(self, name: str) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ info = ctx.get_service_info(name)
+ try:
+ if hasattr(info, "model_dump"):
+ return info.model_dump()
+ if hasattr(info, "dict"):
+ return info.dict()
+ if isinstance(info, dict):
+ return info
+ return {"result": str(info)}
+ except Exception:
+ return {"result": str(info)}
+
+ def get_service_status(self, name: str) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ status = ctx.get_service_status(name)
+ try:
+ if hasattr(status, "model_dump"):
+ return status.model_dump()
+ if hasattr(status, "dict"):
+ return status.dict()
+ if isinstance(status, dict):
+ return status
+ return {"result": str(status)}
+ except Exception:
+ return {"result": str(status)}
+
+
+ def patch_service(self, name: str, updates: Dict[str, Any]) -> bool:
+ ctx = self._agent_ctx or self._context
+ return bool(ctx.patch_service(name, updates))
+
+ async def patch_service_async(self, name: str, updates: Dict[str, Any]) -> bool:
+ ctx = self._agent_ctx or self._context
+ return await ctx.patch_service_async(name, updates)
+
+ def restart_service(self, name: str) -> bool:
+ raise RuntimeError("[AGENT_PROXY] Synchronous restart_service is disabled, please use restart_service_async.")
+
+ async def restart_service_async(self, name: str) -> bool:
+ ctx = self._agent_ctx or self._context
+ return await ctx.restart_service_async(name)
+
+ def use_tool(self, tool_name: str, args: Any = None, **kwargs) -> Any:
+ raise RuntimeError("[AGENT_PROXY] Synchronous use_tool is disabled, please use call_tool_async.")
+
+ async def check_services_async(self) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return await ctx.check_services_async()
+
+ async def get_service_info_async(self, name: str) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ info = await ctx.get_service_info_async(name)
+ try:
+ if hasattr(info, "model_dump"):
+ return info.model_dump()
+ if hasattr(info, "dict"):
+ return info.dict()
+ if isinstance(info, dict):
+ return info
+ return {"result": str(info)}
+ except Exception:
+ return {"result": str(info)}
+
+ async def get_service_status_async(self, name: str) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ status = await ctx.get_service_status_async(name)
+ try:
+ if hasattr(status, "model_dump"):
+ return status.model_dump()
+ if hasattr(status, "dict"):
+ return status.dict()
+ if isinstance(status, dict):
+ return status
+ return {"result": str(status)}
+ except Exception:
+ return {"result": str(status)}
+
+ # ---- Name mapping ----
+ def map_local(self, name: str) -> str:
+ from .agent_service_mapper import AgentServiceMapper
+ # If global name, try rsplit to extract local
+ if AgentServiceMapper.is_any_agent_service(name):
+ try:
+ parts = name.rsplit("_byagent_", 1)
+ return parts[0] if len(parts) == 2 else name
+ except Exception:
+ return name
+ return name
+
+ def map_global(self, name: str) -> str:
+ from .agent_service_mapper import AgentServiceMapper
+ return AgentServiceMapper(self._agent_id).to_global_name(name)
+
+ # ---- Adapters (delegations) ----
+ def for_langchain(self, response_format: str = "text"):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_langchain(response_format=response_format)
+
+ def for_llamaindex(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_llamaindex()
+
+ def for_crewai(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_crewai()
+
+ def for_langgraph(self, response_format: str = "text"):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_langgraph(response_format=response_format)
+
+ def for_autogen(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_autogen()
+
+ def for_semantic_kernel(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_semantic_kernel()
+
+ def for_openai(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.for_openai()
+
+ # ---- Hub MCP helpers ----
+ def hub_http(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/mcp", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """
+ 将当前 Agent 暴露为 HTTP MCP 端点。
+
+ Args:
+ port: 监听端口
+ host: 监听地址
+ path: HTTP 路径
+ background: 是否在后台线程运行
+ show_banner: 是否显示 FastMCP 启动横幅
+ **fastmcp_kwargs: 透传给 FastMCP 的参数
+ """
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._agent_ctx or self._context,
+ transport="http",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_sse(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/sse", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前 Agent 暴露为 SSE MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._agent_ctx or self._context,
+ transport="sse",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_stdio(self, *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前 Agent 暴露为 stdio MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._agent_ctx or self._context,
+ transport="stdio",
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ # ---- Sessions (delegations) ----
+ def with_session(self, session_id: str):
+ ctx = self._agent_ctx or self._context
+ return ctx.with_session(session_id)
+
+ async def with_session_async(self, session_id: str):
+ ctx = self._agent_ctx or self._context
+ return await ctx.with_session_async(session_id)
+
+ def create_session(self, session_id: str, user_session_id: str = None):
+ ctx = self._agent_ctx or self._context
+ return ctx.create_session(session_id, user_session_id)
+
+ def find_session(self, session_id: str = None, is_user_session_id: bool = False):
+ ctx = self._agent_ctx or self._context
+ return ctx.find_session(session_id, is_user_session_id)
+
+ def get_session(self, session_id: str):
+ ctx = self._agent_ctx or self._context
+ return ctx.get_session(session_id)
+
+ def list_sessions(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.list_sessions()
+
+ def close_all_sessions(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.close_all_sessions()
+
+ def cleanup_sessions(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.cleanup_sessions()
+
+ def restart_sessions(self):
+ ctx = self._agent_ctx or self._context
+ return ctx.restart_sessions()
+
+ def find_user_session(self, user_session_id: str):
+ ctx = self._agent_ctx or self._context
+ return ctx.find_user_session(user_session_id)
+
+ def create_shared_session(self, session_id: str, shared_id: str):
+ ctx = self._agent_ctx or self._context
+ return ctx.create_shared_session(session_id, shared_id)
+
+ # ---- Lifecycle / waiters ----
+ def wait_service(self, client_id_or_service_name: str, status = 'healthy', timeout: float = 10.0, raise_on_timeout: bool = False) -> bool:
+ ctx = self._agent_ctx or self._context
+ return ctx.wait_service(client_id_or_service_name, status, timeout, raise_on_timeout)
+
+ async def wait_service_async(self, client_id_or_service_name: str, status = 'healthy', timeout: float = 10.0, raise_on_timeout: bool = False) -> bool:
+ ctx = self._agent_ctx or self._context
+ return await ctx.wait_service_async(client_id_or_service_name, status, timeout, raise_on_timeout)
+
+ def init_service(self, client_id_or_service_name: str = None, *, client_id: str = None, service_name: str = None):
+ ctx = self._agent_ctx or self._context
+ return ctx.init_service(client_id_or_service_name, client_id=client_id, service_name=service_name)
+
+ async def init_service_async(self, client_id_or_service_name: str = None, *, client_id: str = None, service_name: str = None):
+ ctx = self._agent_ctx or self._context
+ return await ctx.init_service_async(client_id_or_service_name, client_id=client_id, service_name=service_name)
+
+ # ---- Advanced features ----
+ def import_api(self, api_url: str, api_name: str = None):
+ ctx = self._agent_ctx or self._context
+ return ctx.import_api(api_url, api_name)
+
+ async def import_api_async(self, api_url: str, api_name: str = None):
+ ctx = self._agent_ctx or self._context
+ return await ctx.import_api_async(api_url, api_name)
+
+ def reset_mcp_json_file(self) -> bool:
+ ctx = self._agent_ctx or self._context
+ return ctx.reset_mcp_json_file()
+
+ async def reset_mcp_json_file_async(self, scope: str = "all") -> bool:
+ ctx = self._agent_ctx or self._context
+ return await ctx.reset_mcp_json_file_async(scope)
+
+ # ---- Tool lookup ----
+ def find_tool(self, tool_name: str):
+ from .tool_proxy import ToolProxy
+ return ToolProxy(self._agent_ctx or self._context, tool_name, scope='context')
+
+ # ---- 工具集管理方法 ----
+ def add_tools(self, service, tools) -> 'AgentProxy':
+ """
+ 添加工具到当前可用集合
+
+ Args:
+ service: 服务标识(服务名称、ServiceProxy 或 "_all_services")
+ tools: 工具标识(工具名称列表或 "_all_tools")
+
+ Returns:
+ self (支持链式调用)
+ """
+ ctx = self._agent_ctx or self._context
+ ctx.add_tools(service=service, tools=tools)
+ return self
+
+ def remove_tools(self, service, tools) -> 'AgentProxy':
+ """
+ 从当前可用集合移除工具
+
+ Args:
+ service: 服务标识(服务名称、ServiceProxy 或 "_all_services")
+ tools: 工具标识(工具名称列表或 "_all_tools")
+
+ Returns:
+ self (支持链式调用)
+ """
+ ctx = self._agent_ctx or self._context
+ ctx.remove_tools(service=service, tools=tools)
+ return self
+
+ def reset_tools(self, service) -> 'AgentProxy':
+ """
+ 重置服务的工具集为默认状态
+
+ Args:
+ service: 服务标识(服务名称、ServiceProxy 或 "_all_services")
+
+ Returns:
+ self (支持链式调用)
+ """
+ ctx = self._agent_ctx or self._context
+ ctx.reset_tools(service=service)
+ return self
+
+ def get_tool_set_info(self, service) -> Dict[str, Any]:
+ """
+ 获取服务的工具集信息
+
+ Args:
+ service: 服务标识(服务名称或 ServiceProxy)
+
+ Returns:
+ 工具集信息字典
+ """
+ ctx = self._agent_ctx or self._context
+ return ctx.get_tool_set_info(service=service)
+
+ def get_tool_set_summary(self) -> Dict[str, Any]:
+ """
+ 获取工具集摘要
+
+ Returns:
+ 摘要信息字典
+ """
+ ctx = self._agent_ctx or self._context
+ return ctx.get_tool_set_summary()
+
+ # ---- Resources & Prompts ----
+ def list_resources(self, service_name: str = None) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.list_resources(service_name)
+
+ def list_resource_templates(self, service_name: str = None) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.list_resource_templates(service_name)
+
+ def read_resource(self, uri: str, service_name: str = None) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.read_resource(uri, service_name)
+
+ def list_prompts(self, service_name: str = None) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.list_prompts(service_name)
+
+ def get_prompt(self, name: str, arguments: Dict[str, Any] = None, service_name: str = None) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.get_prompt(name, arguments, service_name)
+
+ def list_changed_tools(self, service_name: str = None, force_refresh: bool = False) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.list_changed_tools(service_name, force_refresh)
+
+ # ---- Config management ----
+ def reset_config(self) -> bool:
+ ctx = self._agent_ctx or self._context
+ return bool(ctx.reset_config())
+
+ def show_config(self) -> Dict[str, Any]:
+ ctx = self._agent_ctx or self._context
+ return ctx.show_config()
+
+ # ---- Escape hatch ----
+ def get_context(self):
+ return self._agent_ctx or self._context
+
+ # ---- Compatibility: delegate unknown attrs to agent-scoped context ----
+ def __getattr__(self, name: str):
+ target = self._agent_ctx or self._context
+ return getattr(target, name)
diff --git a/src/mcpstore/core/context/agent_service_mapper.py b/src/mcpstore/core/context/agent_service_mapper.py
new file mode 100644
index 00000000..3ea6c287
--- /dev/null
+++ b/src/mcpstore/core/context/agent_service_mapper.py
@@ -0,0 +1,257 @@
+"""
+Agent Service Name Mapper
+
+Responsible for converting between Agent's local names and global names:
+- Local names: Original service names seen by Agent (e.g., "demo")
+- Global names: Internal storage names with suffix (e.g., "demobyagent1")
+
+Design principles:
+1. Agent only sees original names in its own space
+2. Internal storage and synchronization use global names with suffix
+3. Provide bidirectional conversion and filtering functions
+"""
+
+import logging
+from typing import Dict, Any, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class AgentServiceMapper:
+ """Agent service name mapper"""
+
+ def __init__(self, agent_id: str):
+ """
+ Initialize mapper
+
+ Args:
+ agent_id: Agent ID
+ """
+ self.agent_id = agent_id
+ self.suffix = f"_byagent_{agent_id}"
+
+ def to_global_name(self, local_name: str) -> str:
+ """
+ Convert local name to global name
+
+ Args:
+ local_name: Original service name seen by Agent
+
+ Returns:
+ Global storage service name with suffix (format: service_byagent_agentid)
+ """
+ return f"{local_name}{self.suffix}"
+
+ def to_local_name(self, global_name: str) -> str:
+ """
+ Convert global name to local name
+
+ Args:
+ global_name: Global storage service name with suffix
+
+ Returns:
+ Original service name seen by Agent
+ """
+ if global_name.endswith(self.suffix):
+ return global_name[:-len(self.suffix)]
+ return global_name
+
+ def is_agent_service(self, global_name: str) -> bool:
+ """
+ Determine if service belongs to current Agent
+
+ Args:
+ global_name: Global service name
+
+ Returns:
+ Whether it belongs to current Agent
+ """
+ return global_name.endswith(self.suffix)
+
+ @staticmethod
+ def is_any_agent_service(service_name: str) -> bool:
+ """
+ Determine if service belongs to any Agent (static method)
+
+ Args:
+ service_name: Service name to check
+
+ Returns:
+ Whether it's an Agent service (contains _byagent_ pattern)
+ """
+ return "_byagent_" in service_name
+
+ @staticmethod
+ def parse_agent_service_name(global_name: str) -> tuple[str, str]:
+ """
+ Parse Agent service name to extract agent_id and local_name
+
+ Args:
+ global_name: Global service name (format: service_byagent_agentid)
+
+ Returns:
+ Tuple of (agent_id, local_name)
+
+ Raises:
+ ValueError: If the service name format is invalid
+ """
+ if not AgentServiceMapper.is_any_agent_service(global_name):
+ raise ValueError(f"Not an Agent service: {global_name}")
+
+ # 允许 agent_id 含有下划线等字符;只要包含分隔符即可
+ if "_byagent_" not in global_name:
+ raise ValueError(f"Invalid Agent service name format: {global_name}")
+
+ local_name, agent_id = global_name.split("_byagent_", 1)
+ if not local_name or not agent_id:
+ raise ValueError(f"Invalid Agent service name format: {global_name}")
+
+ # 放宽校验:不再限制 agent_id 中的下划线,保持单一分隔符规则
+ return agent_id.strip(), local_name.strip()
+
+ def filter_agent_services(self, global_services: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 从全局服务中过滤出属于当前Agent的服务,并转换为本地名称
+
+ Args:
+ global_services: 全局服务配置字典
+
+ Returns:
+ 本地服务配置字典(使用原始名称)
+ """
+ local_services = {}
+
+ for global_name, config in global_services.items():
+ if self.is_agent_service(global_name):
+ local_name = self.to_local_name(global_name)
+ local_services[local_name] = config
+ logger.debug(f"Mapped service: {global_name} -> {local_name}")
+
+ return local_services
+
+ def convert_service_list_to_local(self, global_service_infos: List[Any]) -> List[Any]:
+ """
+ 将全局服务信息列表转换为本地服务信息列表
+
+ Args:
+ global_service_infos: 全局服务信息列表
+
+ Returns:
+ 本地服务信息列表(使用原始名称)
+ """
+ local_service_infos = []
+
+ for service_info in global_service_infos:
+ if self.is_agent_service(service_info.name):
+ # 创建新的服务信息对象,使用本地名称
+ local_name = self.to_local_name(service_info.name)
+
+ # 复制服务信息,但使用本地名称
+ # 注意:ServiceInfo没有tools属性,工具信息需要单独获取
+ local_service_info = type(service_info)(
+ name=local_name,
+ transport_type=service_info.transport_type,
+ status=service_info.status,
+ tool_count=service_info.tool_count,
+ keep_alive=service_info.keep_alive,
+ url=getattr(service_info, 'url', ''),
+ working_dir=getattr(service_info, 'working_dir', None),
+ env=getattr(service_info, 'env', None),
+ last_heartbeat=getattr(service_info, 'last_heartbeat', None),
+ command=getattr(service_info, 'command', None),
+ args=getattr(service_info, 'args', None),
+ package_name=getattr(service_info, 'package_name', None),
+ state_metadata=getattr(service_info, 'state_metadata', None),
+ last_state_change=getattr(service_info, 'last_state_change', None),
+ client_id=getattr(service_info, 'client_id', None),
+ config=getattr(service_info, 'config', {}) # [REFACTOR] 复制config字段
+ )
+
+ local_service_infos.append(local_service_info)
+ logger.debug(f"Converted service info: {service_info.name} -> {local_name}")
+
+ return local_service_infos
+
+
+
+ def find_global_tool_name(self, local_tool_name: str, available_tools: List[str]) -> Optional[str]:
+ """
+ 根据本地工具名称查找对应的全局工具名称
+
+ Args:
+ local_tool_name: 本地工具名称(如 "demo_get_weather")
+ available_tools: 可用的全局工具名称列表
+
+ Returns:
+ 对应的全局工具名称,如果找不到则返回None
+ """
+ # 解析本地工具名称
+ if "_" not in local_tool_name:
+ # 如果没有下划线,可能是直接的工具名
+ return None
+
+ local_service_name, tool_suffix = local_tool_name.split("_", 1)
+ global_service_name = self.to_global_name(local_service_name)
+ expected_global_tool_name = f"{global_service_name}_{tool_suffix}"
+
+ # 在可用工具中查找
+ if expected_global_tool_name in available_tools:
+ logger.debug(f"Found global tool: {local_tool_name} -> {expected_global_tool_name}")
+ return expected_global_tool_name
+
+ # 如果找不到精确匹配,尝试模糊匹配
+ for global_tool_name in available_tools:
+ if global_tool_name.startswith(f"{global_service_name}_"):
+ tool_part = global_tool_name[len(f"{global_service_name}_"):]
+ if tool_part == tool_suffix:
+ logger.debug(f"Found global tool (fuzzy): {local_tool_name} -> {global_tool_name}")
+ return global_tool_name
+
+ logger.warning(f"Could not find global tool for local tool: {local_tool_name}")
+ return None
+
+ def convert_config_to_local(self, global_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 将全局配置转换为本地配置(Agent视角)
+
+ Args:
+ global_config: 全局配置(包含所有服务)
+
+ Returns:
+ 本地配置(只包含当前Agent的服务,使用原始名称)
+ """
+ if "mcpServers" not in global_config:
+ return {"mcpServers": {}}
+
+ local_servers = self.filter_agent_services(global_config["mcpServers"])
+
+ return {
+ "mcpServers": local_servers,
+ # 保留其他配置项
+ **{k: v for k, v in global_config.items() if k != "mcpServers"}
+ }
+
+ def convert_config_to_global(self, local_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 将本地配置转换为全局配置(用于存储)
+
+ Args:
+ local_config: 本地配置(使用原始名称)
+
+ Returns:
+ 全局配置(使用带后缀名称)
+ """
+ if "mcpServers" not in local_config:
+ return local_config
+
+ global_servers = {}
+ for local_name, config in local_config["mcpServers"].items():
+ global_name = self.to_global_name(local_name)
+ global_servers[global_name] = config
+ logger.debug(f"Converted config: {local_name} -> {global_name}")
+
+ return {
+ "mcpServers": global_servers,
+ # 保留其他配置项
+ **{k: v for k, v in local_config.items() if k != "mcpServers"}
+ }
diff --git a/src/mcpstore/core/context/agent_statistics.py b/src/mcpstore/core/context/agent_statistics.py
new file mode 100644
index 00000000..999841db
--- /dev/null
+++ b/src/mcpstore/core/context/agent_statistics.py
@@ -0,0 +1,207 @@
+"""
+MCPStore Agent Statistics Module
+Implementation of Agent statistics functionality
+"""
+
+import logging
+
+from mcpstore.core.models.agent import AgentsSummary, AgentStatistics, AgentServiceSummary
+
+logger = logging.getLogger(__name__)
+
+class AgentStatisticsMixin:
+ """Agent statistics mixin class"""
+
+ def get_agents_summary(self) -> AgentsSummary:
+ """
+ Get summary information for all Agents (synchronous version)
+
+ Returns:
+ AgentsSummary: Agent summary information
+ """
+ return self._run_async_via_bridge(
+ self.get_agents_summary_async(),
+ op_name="agent_statistics.get_agents_summary"
+ )
+
+ async def get_agents_summary_async(self) -> AgentsSummary:
+ """
+ Get summary information for all Agents (asynchronous version)
+
+ Returns:
+ AgentsSummary: Agent summary information
+ """
+ try:
+ # [REFACTOR] Get all Agent IDs from Registry cache
+ logger.info(" [AGENT_STATS] Starting to get Agent statistics...")
+ all_agent_ids = await self._store.registry.get_all_agent_ids_async()
+ logger.info(f" [AGENT_STATS] Agent IDs retrieved from Registry cache: {all_agent_ids}")
+
+ # Statistical information
+ total_agents = len(all_agent_ids)
+ active_agents = 0
+ total_services = 0
+ total_tools = 0
+
+ agent_details = []
+
+ for agent_id in all_agent_ids:
+ try:
+ # Get Agent statistics information
+ logger.info(f" [AGENT_STATS] Starting to get detailed statistics for Agent {agent_id}...")
+ agent_stats = await self._get_agent_statistics(agent_id)
+ logger.info(f" [AGENT_STATS] Agent {agent_id} statistics completed: {agent_stats.service_count} services, {agent_stats.tool_count} tools")
+
+ if agent_stats.is_active:
+ active_agents += 1
+
+ total_services += agent_stats.service_count
+ total_tools += agent_stats.tool_count
+
+ agent_details.append(agent_stats)
+
+ except Exception as e:
+ logger.warning(f"Failed to get statistics for agent {agent_id}: {e}")
+ # 创建一个错误状态的统计信息
+ error_stats = AgentStatistics(
+ agent_id=agent_id,
+ service_count=0,
+ tool_count=0,
+ healthy_services=0,
+ unhealthy_services=0,
+ total_tool_executions=0,
+ is_active=False,
+ last_activity=None,
+ services=[]
+ )
+ agent_details.append(error_stats)
+
+ # [REFACTOR] 获取Store级别的统计信息
+ store_services = await self._store.list_services()
+ store_tools = await self._store.list_tools()
+
+ return AgentsSummary(
+ total_agents=total_agents,
+ active_agents=active_agents,
+ total_services=total_services,
+ total_tools=total_tools,
+ store_services=len(store_services),
+ store_tools=len(store_tools),
+ agents=agent_details
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get agents summary: {e}")
+ return AgentsSummary(
+ total_agents=0,
+ active_agents=0,
+ total_services=0,
+ total_tools=0,
+ store_services=0,
+ store_tools=0,
+ agents=[]
+ )
+
+ async def _get_agent_statistics(self, agent_id: str) -> AgentStatistics:
+ """
+ 获取单个Agent的详细统计信息
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ AgentStatistics: Agent统计信息
+ """
+ try:
+ # 获取Agent的所有client - 从 pykv 获取
+ logger.info(f" [AGENT_STATS] Getting all clients for Agent {agent_id}...")
+ client_ids = await self._store.registry.get_agent_clients_async(agent_id)
+ logger.info(f" [AGENT_STATS] Agent {agent_id} client list: {client_ids}")
+
+ # 统计服务和工具
+ services = []
+ total_tools = 0
+ is_active = False
+ last_activity = None
+
+ for client_id in client_ids:
+ try:
+ # 获取client配置
+ client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
+ if not client_config:
+ continue
+
+ # [REFACTOR] 简化逻辑:直接检查服务状态来判断client是否活跃
+ # 不再调用不存在的get_client_status方法
+
+ # 统计服务(新架构:从 client 实体的 services 列表获取服务名称)
+ services = client_config.get("services", []) if isinstance(client_config, dict) else []
+ for service_name in services:
+ try:
+ # [REFACTOR] 使用正确的Registry方法获取服务工具(异步)
+ service_tools = await self._store.registry.get_tools_for_service_async(agent_id, service_name)
+ tool_count = len(service_tools) if service_tools else 0
+ total_tools += tool_count
+
+ # [REFACTOR] 使用正确的Registry方法获取服务状态(异步)
+ service_state = await self._store.registry.get_service_state_async(agent_id, service_name)
+
+ # 检查服务是否活跃(有工具且状态不是DISCONNECTED)
+ from mcpstore.core.models.service import ServiceConnectionState
+ if service_state not in [ServiceConnectionState.DISCONNECTED, ServiceConnectionState.UNREACHABLE]:
+ is_active = True
+
+ service_summary = AgentServiceSummary(
+ service_name=service_name,
+ service_type="local" if service_config.get("command") else "remote",
+ status=service_state,
+ tool_count=tool_count,
+ client_id=client_id
+ )
+ services.append(service_summary)
+
+ except Exception as e:
+ logger.warning(f"Failed to get service {service_name} stats for agent {agent_id}: {e}")
+ # 添加错误状态的服务
+ from mcpstore.core.models.service import ServiceConnectionState
+ error_service = AgentServiceSummary(
+ service_name=service_name,
+ service_type="unknown",
+ status=ServiceConnectionState.DISCONNECTED,
+ tool_count=0,
+ client_id=client_id
+ )
+ services.append(error_service)
+
+ except Exception as e:
+ logger.warning(f"Failed to process client {client_id} for agent {agent_id}: {e}")
+
+ # 统计健康和不健康的服务
+ healthy_services = len([s for s in services if s.status in ["healthy", "warning"]])
+ unhealthy_services = len(services) - healthy_services
+
+ return AgentStatistics(
+ agent_id=agent_id,
+ service_count=len(services),
+ tool_count=total_tools,
+ healthy_services=healthy_services,
+ unhealthy_services=unhealthy_services,
+ total_tool_executions=0, # TODO: 实现工具执行统计
+ is_active=is_active,
+ last_activity=last_activity,
+ services=services
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to get statistics for agent {agent_id}: {e}")
+ return AgentStatistics(
+ agent_id=agent_id,
+ service_count=0,
+ tool_count=0,
+ healthy_services=0,
+ unhealthy_services=0,
+ total_tool_executions=0,
+ is_active=False,
+ last_activity=None,
+ services=[]
+ )
diff --git a/src/mcpstore/core/context/async_safe_service_management.py b/src/mcpstore/core/context/async_safe_service_management.py
new file mode 100644
index 00000000..2660ce46
--- /dev/null
+++ b/src/mcpstore/core/context/async_safe_service_management.py
@@ -0,0 +1,333 @@
+"""
+异步安全的服务管理
+
+修复service_management.py中的嵌套异步调用问题
+"""
+
+import logging
+from typing import Dict, Any, Optional
+
+from .service_management import ServiceManagement
+from ..utils.deadlock_safe_async_helper import get_deadlock_safe_helper
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncSafeServiceManagement(ServiceManagement):
+ """
+ 异步安全的服务管理
+
+ 核心改进:
+ 1. 消除get_service_info中的强制后台调用
+ 2. 使用缓存优先策略减少异步调用
+ 3. 提供完整的异步调用链
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # 使用死锁安全的异步助手
+ self._safe_sync_helper = get_deadlock_safe_helper()
+
+ # 服务信息缓存(避免重复的异步调用)
+ self._service_info_cache: Dict[str, Dict[str, Any]] = {}
+ self._cache_timeout = 5.0 # 5秒缓存
+
+ logger.debug("AsyncSafeServiceManagement initialized with deadlock-safe helper")
+
+ def get_service_info(self, name: str, use_cache: bool = True) -> Dict[str, Any]:
+ """
+ 获取服务信息 - 同步版本
+
+ 使用缓存优先策略,避免嵌套异步调用
+
+ Args:
+ name: 服务名称
+ use_cache: 是否使用缓存(默认True)
+
+ Returns:
+ 服务信息字典
+ """
+ logger.debug(f"[ASYNC_SAFE_SM] Getting service info: {name}, use_cache={use_cache}")
+
+ # 检查缓存
+ if use_cache:
+ cached_info = self._get_from_cache(name)
+ if cached_info is not None:
+ logger.debug(f"[ASYNC_SAFE_SM] Service info from cache: {name}")
+ return cached_info
+
+ try:
+ # 方法1:从内存状态直接构造信息(避免异步调用)
+ service_info = self._get_service_info_from_memory(name)
+
+ if service_info is not None:
+ # 更新缓存
+ self._update_cache(name, service_info)
+ logger.debug(f"[ASYNC_SAFE_SM] Service info from memory: {name}")
+ return service_info
+
+ # 方法2:使用异步安全助手进行调用
+ if self._registry and hasattr(self._registry, 'get_tool_info'):
+ # 使用注册表的同步方法
+ service_info = self._get_service_info_from_registry(name)
+
+ if service_info is not None:
+ self._update_cache(name, service_info)
+ logger.debug(f"[ASYNC_SAFE_SM] Service info from registry: {name}")
+ return service_info
+
+ # 方法3:最后才考虑异步调用,但使用死锁安全机制
+ logger.debug(f"[ASYNC_SAFE_SM] Falling back to async call for: {name}")
+ return self._get_service_info_async_safe(name)
+
+ except Exception as e:
+ logger.error(f"[ASYNC_SAFE_SM] Failed to get service info: {name}, error={e}")
+ # 返回基本错误信息而不是抛出异常
+ return {
+ "name": name,
+ "error": str(e),
+ "status": "error",
+ "is_connected": False,
+ "tools": []
+ }
+
+ def _get_from_cache(self, name: str) -> Optional[Dict[str, Any]]:
+ """从缓存获取服务信息"""
+ import time
+
+ cache_entry = self._service_info_cache.get(name)
+ if cache_entry is None:
+ return None
+
+ cached_time, cached_info = cache_entry
+ current_time = time.time()
+
+ if current_time - cached_time > self._cache_timeout:
+ # 缓存过期
+ del self._service_info_cache[name]
+ return None
+
+ return cached_info
+
+ def _update_cache(self, name: str, info: Dict[str, Any]):
+ """更新服务信息缓存"""
+ import time
+ self._service_info_cache[name] = (time.time(), info)
+
+ def _get_service_info_from_memory(self, name: str) -> Optional[Dict[str, Any]]:
+ """从内存状态获取服务信息"""
+ try:
+ if not self._registry:
+ return None
+
+ # 检查服务是否存在于内存中
+ agent_id = self._get_agent_id_for_service(name)
+ if agent_id is None:
+ return None
+
+ # 获取工具信息(从内存)
+ tools_info = []
+ if hasattr(self._registry, 'tool_to_session_map'):
+ tool_to_session = self._registry.tool_to_session_map.get(agent_id, {})
+
+ for tool_name, session in tool_to_session.items():
+ # 简单检查工具是否属于该服务
+ service_name = self._find_service_for_tool(agent_id, tool_name, name)
+ if service_name == name:
+ tools_info.append({
+ "name": tool_name,
+ "display_name": tool_name,
+ "description": "Tool from memory cache",
+ "is_connected": getattr(session, 'is_connected', False)
+ })
+
+ # 构造基本服务信息
+ service_info = {
+ "name": name,
+ "agent_id": agent_id,
+ "is_connected": len(tools_info) > 0,
+ "tools": tools_info,
+ "tools_count": len(tools_info),
+ "status": "connected" if tools_info else "disconnected",
+ "source": "memory_cache"
+ }
+
+ return service_info
+
+ except Exception as e:
+ logger.debug(f"[ASYNC_SAFE_SM] Failed to get service info from memory: {name}, error={e}")
+ return None
+
+ def _get_service_info_from_registry(self, name: str) -> Optional[Dict[str, Any]]:
+ """从注册表获取服务信息(同步方法)"""
+ try:
+ if not self._registry:
+ return None
+
+ agent_id = self._get_agent_id_for_service(name)
+ if agent_id is None:
+ return None
+
+ # 使用注册表的同步方法获取工具信息
+ if hasattr(self._registry, 'get_all_tools'):
+ tools_list = self._registry.get_all_tools(agent_id)
+
+ # 过滤出属于当前服务的工具
+ service_tools = []
+ for tool_info in tools_list:
+ if tool_info.get('service_name') == name:
+ service_tools.append({
+ "name": tool_info.get('name'),
+ "display_name": tool_info.get('display_name', tool_info.get('name')),
+ "description": tool_info.get('description', 'No description available'),
+ "is_connected": tool_info.get('is_connected', False)
+ })
+
+ return {
+ "name": name,
+ "agent_id": agent_id,
+ "is_connected": len(service_tools) > 0,
+ "tools": service_tools,
+ "tools_count": len(service_tools),
+ "status": "connected" if service_tools else "disconnected",
+ "source": "registry_sync"
+ }
+
+ return None
+
+ except Exception as e:
+ logger.debug(f"[ASYNC_SAFE_SM] Failed to get service info from registry: {name}, error={e}")
+ return None
+
+ def _get_service_info_async_safe(self, name: str) -> Dict[str, Any]:
+ """使用死锁安全机制获取服务信息"""
+ try:
+ # 使用死锁安全的异步助手
+ if hasattr(self, 'get_service_info_async'):
+ return self._safe_sync_helper.run_async(
+ self.get_service_info_async(name),
+ timeout=10.0,
+ operation_name=f"get_service_info_async:{name}",
+ force_background=True
+ )
+ else:
+ # 降级到基本错误信息
+ return {
+ "name": name,
+ "error": "Async method not available",
+ "status": "error",
+ "is_connected": False,
+ "tools": []
+ }
+
+ except Exception as e:
+ logger.error(f"[ASYNC_SAFE_SM] Async-safe call failed: {name}, error={e}")
+ return {
+ "name": name,
+ "error": str(e),
+ "status": "error",
+ "is_connected": False,
+ "tools": []
+ }
+
+ def _get_agent_id_for_service(self, name: str) -> Optional[str]:
+ """获取服务对应的agent_id"""
+ if self._context_type.value == "store":
+ return "global_agent_store"
+ elif self._client_manager:
+ return self._client_manager.get_agent_id()
+ return None
+
+ def _find_service_for_tool(self, agent_id: str, tool_name: str, target_service: str) -> Optional[str]:
+ """查找工具所属的服务"""
+ try:
+ if not hasattr(self._registry, 'sessions'):
+ return None
+
+ agent_sessions = self._registry.sessions.get(agent_id, {})
+
+ for service_name, session in agent_sessions.items():
+ if service_name == target_service:
+ # 检查该会话是否有这个工具
+ if hasattr(self._registry, 'tool_to_session_map'):
+ tool_to_session = self._registry.tool_to_session_map.get(agent_id, {})
+ if tool_to_session.get(tool_name) is session:
+ return service_name
+
+ return None
+
+ except Exception as e:
+ logger.debug(f"[ASYNC_SAFE_SM] Failed to find service for tool: {tool_name}, error={e}")
+ return None
+
+ def clear_cache(self, name: Optional[str] = None):
+ """清除服务信息缓存"""
+ if name:
+ self._service_info_cache.pop(name, None)
+ logger.debug(f"[ASYNC_SAFE_SM] Cache cleared for service: {name}")
+ else:
+ self._service_info_cache.clear()
+ logger.debug("[ASYNC_SAFE_SM] All service info cache cleared")
+
+ def get_cache_stats(self) -> Dict[str, Any]:
+ """获取缓存统计信息"""
+ import time
+ current_time = time.time()
+
+ cache_stats = {
+ "total_cached": len(self._service_info_cache),
+ "valid_entries": 0,
+ "expired_entries": 0,
+ "entries": []
+ }
+
+ for name, (cached_time, info) in self._service_info_cache.items():
+ age = current_time - cached_time
+ is_valid = age <= self._cache_timeout
+
+ if is_valid:
+ cache_stats["valid_entries"] += 1
+ else:
+ cache_stats["expired_entries"] += 1
+
+ cache_stats["entries"].append({
+ "name": name,
+ "age": age,
+ "is_valid": is_valid,
+ "tools_count": info.get("tools_count", 0)
+ })
+
+ return cache_stats
+
+
+class AsyncSafeServiceManagementFactory:
+ """异步安全服务管理工厂"""
+
+ @staticmethod
+ def create_service_management(*args, **kwargs) -> AsyncSafeServiceManagement:
+ """创建异步安全的服务管理实例"""
+ logger.debug("Creating AsyncSafeServiceManagement instance")
+ return AsyncSafeServiceManagement(*args, **kwargs)
+
+ @staticmethod
+ def migrate_from_standard_management(standard_management: ServiceManagement) -> AsyncSafeServiceManagement:
+ """从标准服务管理迁移到异步安全版本"""
+ logger.info("Migrating from standard service management to async-safe version")
+
+ # 创建新的异步安全服务管理
+ async_safe_management = AsyncSafeServiceManagement.__new__(AsyncSafeServiceManagement)
+
+ # 复制所有必要的状态
+ async_safe_management._context_type = standard_management._context_type
+ async_safe_management._client_manager = standard_management._client_manager
+ async_safe_management._store = standard_management._store
+ async_safe_management._sync_helper = standard_management._sync_helper
+ async_safe_management._registry = standard_management._registry
+
+ # 初始化异步安全特有属性
+ async_safe_management._safe_sync_helper = get_deadlock_safe_helper()
+ async_safe_management._service_info_cache = {}
+ async_safe_management._cache_timeout = 5.0
+
+ logger.info("Service management migration completed successfully")
+ return async_safe_management
\ No newline at end of file
diff --git a/src/mcpstore/core/context/base_context.py b/src/mcpstore/core/context/base_context.py
new file mode 100644
index 00000000..4ca8f80f
--- /dev/null
+++ b/src/mcpstore/core/context/base_context.py
@@ -0,0 +1,500 @@
+"""
+MCPStore Base Context Module
+Core context classes and basic functionality
+"""
+
+import asyncio
+import logging
+from typing import Dict, List, Optional, Any, TYPE_CHECKING
+
+from mcpstore.extensions.monitoring import MonitoringManager
+from mcpstore.extensions.monitoring.analytics import get_monitoring_manager
+from .agent_service_mapper import AgentServiceMapper
+from .tool_transformation import get_transformation_manager
+from ..bridge import get_async_bridge
+from ..integration.openapi_integration import get_openapi_manager
+from ..performance import get_performance_optimizer
+
+# Create logger instance
+logger = logging.getLogger(__name__)
+
+from .types import ContextType
+
+if TYPE_CHECKING:
+ from ...adapters.langchain_adapter import LangChainAdapter
+ from ..configuration.unified_config import UnifiedConfigManager
+
+
+
+# Import mixin classes
+from .service_operations import ServiceOperationsMixin
+from .tool_operations import ToolOperationsMixin
+from .service_management import ServiceManagementMixin
+from .session_management import SessionManagementMixin
+from .advanced_features import AdvancedFeaturesMixin
+from .resources_prompts import ResourcesPromptsMixin
+from .agent_statistics import AgentStatisticsMixin
+from .service_proxy import ServiceProxy
+from .internal.context_kernel import create_kernel
+from .store_proxy import StoreProxy
+from .cache_proxy import CacheProxy
+
+class MCPStoreContext(
+ ServiceOperationsMixin,
+ ToolOperationsMixin,
+ ServiceManagementMixin,
+ SessionManagementMixin,
+ AdvancedFeaturesMixin,
+ ResourcesPromptsMixin,
+ AgentStatisticsMixin
+):
+ """
+ MCPStore context class
+ Responsible for handling specific business operations and maintaining operational context environment
+ """
+ def __init__(self, store: 'MCPStore', agent_id: Optional[str] = None):
+ self._store = store
+ self._agent_id = agent_id
+ self._context_type = ContextType.STORE if agent_id is None else ContextType.AGENT
+ self._bridge = get_async_bridge()
+
+
+ # Initialize wait strategy for service operations
+ from .service_operations import AddServiceWaitStrategy
+ self.wait_strategy = AddServiceWaitStrategy()
+
+ # Initialize session management
+ SessionManagementMixin.__init__(self)
+
+ # New feature manager
+ self._transformation_manager = get_transformation_manager()
+ self._openapi_manager = get_openapi_manager()
+ self._performance_optimizer = get_performance_optimizer()
+ self._monitoring_manager = get_monitoring_manager()
+
+ # Monitoring manager - unified behavior for both branches
+ data_dir = None
+ if hasattr(self._store, '_data_space_manager') and self._store._data_space_manager:
+ data_dir = self._store._data_space_manager.workspace_dir / "monitoring"
+ else:
+ logger.warning("[MONITORING] Data space manager not initialized; monitoring disabled (no fallback path).")
+
+ if data_dir is not None:
+ try:
+ self._monitoring = MonitoringManager(
+ data_dir,
+ self._store.tool_record_max_file_size,
+ self._store.tool_record_retention_days
+ )
+ except Exception as monitor_init_error:
+ logger.warning(f"[MONITORING] Failed to initialize monitoring at data space: {monitor_init_error}")
+ self._monitoring = None
+ else:
+ self._monitoring = None
+
+ # Agent service name mapper
+ # global_agent_store does not use service mapper as it uses original service names
+ if agent_id and agent_id != "global_agent_store":
+ self._service_mapper = AgentServiceMapper(agent_id)
+ else:
+ self._service_mapper = None
+
+ # Extension reserved
+ self._metadata: Dict[str, Any] = {}
+ self._config: Dict[str, Any] = {}
+ self._cache: Dict[str, Any] = {}
+ # Per-tool overrides (e.g., flags consumed by adapters like LangChain)
+ # Keyed by "{service_name}:{tool_name}" -> { flag_name: value }
+ self._tool_overrides: Dict[str, Dict[str, Any]] = {}
+
+ # Phase 1: internal kernel for read paths (no external API change)
+ try:
+ self._kernel = create_kernel(self)
+ except Exception:
+ self._kernel = None
+
+ # internal helper for sync methods
+ def _run_async_via_bridge(self, coro, op_name: str, timeout: float | None = None):
+ """使用 Async Orchestrated Bridge 在同步环境中执行协程。"""
+ return self._bridge.run(coro, op_name=op_name, timeout=timeout)
+
+ async def bridge_execute(self, coro, op_name: str | None = None):
+ """
+ 在任意事件循环中安全执行需要访问 pykv 的协程。
+
+ - 如果当前运行在 AOB 的 loop 中,直接 await。
+ - 否则通过 asyncio.to_thread 调用同步桥,保证 Redis Future 仍在 AOB loop 上执行。
+ """
+ op_label = op_name or "context_bridge_execute"
+ bridge_loop = getattr(self._bridge, "_loop", None)
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if bridge_loop and running_loop is bridge_loop:
+ return await coro
+
+ if running_loop is None:
+ return self._bridge.run(coro, op_name=op_label)
+
+ return await asyncio.to_thread(self._bridge.run, coro, op_name=op_label)
+
+ # ---- Objectified entries ----
+ def for_store(self) -> 'StoreProxy':
+ """Return StoreProxy for objectified store-view."""
+ return StoreProxy(self)
+
+ def find_cache(self) -> 'CacheProxy':
+ """Return CacheProxy (scope depends on context)."""
+ scope = "global" if self._context_type == ContextType.STORE else "agent"
+ scope_value = None if scope == "global" else self._agent_id
+ return CacheProxy(self, scope=scope, scope_value=scope_value)
+
+ def find_agent(self, agent_id: str) -> 'AgentProxy':
+ """
+ Find agent proxy with unified caching.
+
+ Uses the centralized AgentProxy caching system to ensure that the same
+ agent_id always returns the same AgentProxy instance across all access
+ methods in the MCPStore.
+
+ Args:
+ agent_id: Unique identifier for the agent
+
+ Returns:
+ AgentProxy: Cached or newly created AgentProxy instance
+ """
+ # Use unified AgentProxy caching system from the store
+ return self._store._get_or_create_agent_proxy(self, agent_id)
+
+ def for_langchain(self, response_format: str = "text") -> 'LangChainAdapter':
+ """Return a LangChain adapter. If a session is active (within with_session),
+ return a session-aware adapter bound to that session; otherwise return the
+ standard context adapter.
+
+ Args:
+ response_format: Adapter-only rendering mode for tool outputs. Supported:
+ - "text" (default): Return merged TextContent as string
+ - "content_and_artifact": Return dict {"text": str, "artifacts": list}
+ """
+ # Avoid top-level import cycles
+ from ...adapters.langchain_adapter import LangChainAdapter, SessionAwareLangChainAdapter
+
+ active = getattr(self, "_active_session", None)
+ if active is not None and getattr(active, "is_active", False):
+ # Implicit session routing: with_session scope auto-binds LangChain tools
+ return SessionAwareLangChainAdapter(self, active, response_format=response_format)
+
+ return LangChainAdapter(self, response_format=response_format)
+
+ def for_llamaindex(self) -> 'LlamaIndexAdapter':
+ """Return a LlamaIndex adapter (FunctionTool) for MCP tools."""
+ from ...adapters.llamaindex_adapter import LlamaIndexAdapter
+ return LlamaIndexAdapter(self)
+
+ def for_crewai(self) -> 'CrewAIAdapter':
+ """Return a CrewAI adapter that reuses LangChain tools for compatibility."""
+ from ...adapters.crewai_adapter import CrewAIAdapter
+ return CrewAIAdapter(self)
+
+ def for_langgraph(self, response_format: str = "text") -> 'LangGraphAdapter':
+ """Return a LangGraph adapter that reuses LangChain tools.
+ Args:
+ response_format: Same as for_langchain(); forwarded to LangChain adapter.
+ """
+ from ...adapters.langgraph_adapter import LangGraphAdapter
+ return LangGraphAdapter(self, response_format=response_format)
+
+ def for_autogen(self) -> 'AutoGenAdapter':
+ """Return an AutoGen adapter that produces Python functions for registration."""
+ from ...adapters.autogen_adapter import AutoGenAdapter
+ return AutoGenAdapter(self)
+
+ def for_semantic_kernel(self) -> 'SemanticKernelAdapter':
+ """Return a Semantic Kernel adapter that produces native function callables."""
+ from ...adapters.semantic_kernel_adapter import SemanticKernelAdapter
+ return SemanticKernelAdapter(self)
+
+ def for_openai(self) -> 'OpenAIAdapter':
+ """Return an OpenAI adapter that produces OpenAI function calling format tools."""
+ from ...adapters.openai_adapter import OpenAIAdapter
+ return OpenAIAdapter(self)
+
+ def find_service(self, service_name: str) -> 'ServiceProxy':
+ """
+ Find specified service and return service proxy object
+
+ Further narrows scope to specific service, providing all operation methods
+ for that service.
+
+ Args:
+ service_name: Service name
+
+ Returns:
+ ServiceProxy: Service proxy object containing all operation methods for the service
+
+ Example:
+ # Store-level usage
+ weather_service = store.for_store().find_service('weather')
+ weather_service.service_info() # Get service details
+ weather_service.list_tools() # List tools
+ weather_service.check_health() # Check health status
+
+ # Agent-level usage
+ demo_service = store.for_agent('demo1').find_service('service1')
+ demo_service.service_info() # Get service details
+ demo_service.restart_service() # Restart service
+ """
+ from .service_proxy import ServiceProxy
+ try:
+ effective = service_name
+ if self._context_type == ContextType.AGENT and getattr(self, '_service_mapper', None):
+ effective = self._service_mapper.to_global_name(service_name)
+ logger.info(f"[FIND_SERVICE] context={self._context_type.name} agent_id={self._agent_id} input='{service_name}' effective='{effective}'")
+ except Exception as e:
+ logger.warning(f"[FIND_SERVICE] mapping_info_failed name='{service_name}' error={e}")
+ return ServiceProxy(self, service_name)
+
+ def find_tool(self, tool_name: str) -> 'ToolProxy':
+ """
+ Find specified tool and return tool proxy object
+
+ Search for tools within current context scope:
+ - Store context: Search tools from all global services
+ - Agent context: Search tools from all services of that Agent
+
+ Args:
+ tool_name: Tool name
+
+ Returns:
+ ToolProxy: Tool proxy object containing all operation methods for the tool
+
+ Example:
+ # Store-level usage
+ weather_tool = store.for_store().find_tool('get_current_weather')
+ weather_tool.tool_info() # Get tool details
+ weather_tool.call_tool({...}) # Call tool
+ weather_tool.usage_stats() # Usage statistics
+
+ # Agent-level usage
+ demo_tool = store.for_agent('demo1').find_tool('search_tool')
+ demo_tool.tool_info() # Get tool details
+ demo_tool.test_call({...}) # Test call
+ """
+ from .tool_proxy import ToolProxy
+ return ToolProxy(self, tool_name, scope='context')
+
+ @property
+ def context_type(self) -> ContextType:
+ """Get context type"""
+ return self._context_type
+
+ @property
+ def agent_id(self) -> Optional[str]:
+ """Get current agent_id"""
+ return self._agent_id
+
+ def get_unified_config(self) -> 'UnifiedConfigManager':
+ """Get unified configuration manager
+
+ Returns:
+ UnifiedConfigManager: Unified configuration manager instance
+ """
+ return self._store._unified_config
+
+ def setup_config(self) -> Dict[str, Any]:
+ """Return a read-only snapshot of setup-time configuration.
+
+ This reflects the effective configuration used during MCPStore.setup_store().
+ The snapshot includes:
+ - mcp_json: Path to mcp.json configuration file
+ - debug_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL, OFF)
+ - static_config: Static configuration dict (monitoring, network, features, etc.)
+ - cache_config: Cache configuration object (MemoryConfig or RedisConfig)
+
+ Returns:
+ Dict[str, Any]: Configuration snapshot dictionary
+ """
+ from copy import deepcopy
+ snap = getattr(self._store, "_setup_snapshot", None)
+ if isinstance(snap, dict):
+ return deepcopy(snap)
+ # Fallback minimal snapshot
+ try:
+ lvl = logging.getLogger().getEffectiveLevel()
+ level_name = (
+ "DEBUG" if lvl <= logging.DEBUG else
+ "INFO" if lvl <= logging.INFO else
+ "WARNING" if lvl <= logging.WARNING else
+ "ERROR" if lvl <= logging.ERROR else
+ "CRITICAL" if lvl <= logging.CRITICAL else "OFF"
+ )
+ except Exception:
+ level_name = "OFF"
+ return {
+ "mcp_json": getattr(self._store.config, "json_path", None),
+ "debug_level": level_name,
+ "static_config": {}
+ }
+
+ # === Monitoring and statistics functionality ===
+
+ def record_api_call(self, response_time: float):
+ """Record API call"""
+ if self._monitoring:
+ self._monitoring.record_api_call(response_time)
+
+ def increment_active_connections(self):
+ """Increment active connection count"""
+ if self._monitoring:
+ self._monitoring.increment_active_connections()
+
+ def decrement_active_connections(self):
+ """Decrement active connection count"""
+ if self._monitoring:
+ self._monitoring.decrement_active_connections()
+
+ def get_tool_records(self, limit: int = 50) -> Dict[str, Any]:
+ """Get tool execution records"""
+ if not self._monitoring:
+ return {
+ "executions": [],
+ "summary": {
+ "total_executions": 0,
+ "by_tool": {},
+ "by_service": {}
+ },
+ "warning": "Monitoring disabled"
+ }
+ return self._monitoring.get_tool_records(limit)
+
+ async def get_tool_records_async(self, limit: int = 50) -> Dict[str, Any]:
+ """Asynchronously get tool execution records"""
+ return self.get_tool_records(limit)
+
+ # === Internal helper methods ===
+
+ def _tool_override_key(self, service_name: str, tool_name: str) -> str:
+ """Compose stable key for tool overrides."""
+ service_safe = service_name or ""
+ return f"{service_safe}:{tool_name}"
+
+ def _set_tool_override(self, service_name: str, tool_name: str, flag: str, value: Any) -> None:
+ """Set an override flag for a specific tool.
+
+ Args:
+ service_name: The service that provides the tool (agent-local or global depending on context view)
+ tool_name: Tool name as exposed by current context's tools view
+ flag: Override flag name, e.g., "return_direct"
+ value: Override value
+ """
+ try:
+ key = self._tool_override_key(service_name, tool_name)
+ if key not in self._tool_overrides:
+ self._tool_overrides[key] = {}
+ self._tool_overrides[key][flag] = value
+ logger.debug(f"[TOOL_OVERRIDE] set {flag}={value} for {key}")
+ except Exception as e:
+ logger.warning(f"[TOOL_OVERRIDE] failed to set override for {service_name}:{tool_name} flag={flag}: {e}")
+
+ def _get_tool_override(self, service_name: str, tool_name: str, flag: str, default: Any = None) -> Any:
+ """Get an override flag value for a tool, or default if not set."""
+ try:
+ key = self._tool_override_key(service_name, tool_name)
+ return self._tool_overrides.get(key, {}).get(flag, default)
+ except Exception:
+ return default
+
+ def _get_all_tool_overrides(self) -> Dict[str, Dict[str, Any]]:
+ """Return a snapshot of all tool overrides."""
+ return dict(self._tool_overrides)
+
+ def _get_available_services(self) -> List[str]:
+ """Get available service list"""
+ try:
+ if self._context_type == ContextType.STORE:
+ services = self._store.for_store().list_services()
+ else:
+ services = self._store.for_agent(self._agent_id).list_services()
+ names: List[str] = []
+ for service in services or []:
+ if isinstance(service, dict):
+ name = service.get("name")
+ if isinstance(name, str):
+ names.append(name)
+ else:
+ try:
+ n = getattr(service, "name", None)
+ if isinstance(n, str):
+ names.append(n)
+ except Exception:
+ pass
+ return names
+ except Exception:
+ return []
+
+ def _extract_original_tool_name(self, display_name: str, service_name: str) -> str:
+ """
+ Extract original tool name from display name
+
+ Args:
+ display_name: Display name (e.g., "weather-api_get_weather")
+ service_name: Service name (e.g., "weather-api")
+
+ Returns:
+ str: Original tool name (e.g., "get_weather")
+ """
+ # Remove service name prefix
+ if display_name.startswith(f"{service_name}_"):
+ return display_name[len(service_name) + 1:]
+ elif display_name.startswith(f"{service_name}__"):
+ return display_name[len(service_name) + 2:]
+ else:
+ return display_name
+
+ def _cleanup_reconnection_queue_for_client(self, client_id: str):
+ """Clean up reconnection queue entries related to specified client"""
+ try:
+ # Find all reconnection entries related to this client
+ if hasattr(self._store.orchestrator, 'smart_reconnection') and self._store.orchestrator.smart_reconnection:
+ reconnection_manager = self._store.orchestrator.smart_reconnection
+
+ # Get all reconnection entries
+ all_entries = reconnection_manager.entries.copy()
+
+ # Find entries to be cleaned up
+ entries_to_remove = []
+ for service_key, entry in all_entries.items():
+ if entry.client_id == client_id:
+ entries_to_remove.append(service_key)
+
+ # Remove entries
+ for service_key in entries_to_remove:
+ reconnection_manager.remove_service(service_key)
+ logger.debug(f"Removed reconnection entry for {service_key}")
+
+ except Exception as e:
+ logger.warning(f"Failed to cleanup reconnection queue for client {client_id}: {e}")
+
+ def _create_validation_function(self, rule: Dict[str, Any]) -> callable:
+ """Create validation function"""
+ def validate(value):
+ if "min_length" in rule and len(str(value)) < rule["min_length"]:
+ raise ValueError(f"Value too short, minimum length: {rule['min_length']}")
+ if "max_length" in rule and len(str(value)) > rule["max_length"]:
+ raise ValueError(f"Value too long, maximum length: {rule['max_length']}")
+ if "pattern" in rule:
+ import re
+ if not re.match(rule["pattern"], str(value)):
+ raise ValueError(f"Value doesn't match pattern: {rule['pattern']}")
+ return validate
+
+ def _extract_service_name(self, tool_name: str) -> str:
+ """Extract service name from tool name"""
+ if "_" in tool_name:
+ return tool_name.split("_")[0]
+ elif "__" in tool_name:
+ return tool_name.split("__")[0]
+ else:
+ return ""
diff --git a/src/mcpstore/core/context/cache_proxy.py b/src/mcpstore/core/context/cache_proxy.py
new file mode 100644
index 00000000..85811d2a
--- /dev/null
+++ b/src/mcpstore/core/context/cache_proxy.py
@@ -0,0 +1,240 @@
+"""
+CacheProxy - 只读缓存代理
+
+为 pykv 三层缓存提供统一的只读访问接口,支持全局/Agent/Service/Tool 视角过滤。
+"""
+
+import asyncio
+import logging
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Sequence
+
+logger = logging.getLogger(__name__)
+
+
+class CacheProxy:
+ """只读缓存代理,按层提供统一读取接口。"""
+
+ def __init__(self, context: "MCPStoreContext", scope: str = "global", scope_value: Optional[str] = None):
+ self._context = context
+ self._scope = scope # global | agent | service | tool
+ self._scope_value = scope_value
+ registry = getattr(context._store, "registry", None)
+ self._cache_layer = None
+ if registry:
+ # 优先使用新版 cache_layer_manager
+ self._cache_layer = getattr(registry, "_cache_layer_manager", None) or getattr(registry, "_cache_layer", None)
+ logger.debug(
+ "[CACHE_PROXY_INIT] scope=%s value=%s cache_layer=%s namespace=%s",
+ scope,
+ scope_value,
+ self._cache_layer.__class__.__name__ if self._cache_layer else None,
+ getattr(self._cache_layer, '_namespace', None) if self._cache_layer else None,
+ )
+ self._bridge = getattr(context, "_bridge", None)
+
+ # === 对外方法(同步)===
+ def read_entity(self, entity_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ return self._run_async(self.read_entity_async(entity_type, key), "cache_proxy.read_entity")
+
+ def read_relation(self, relation_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ return self._run_async(self.read_relation_async(relation_type, key), "cache_proxy.read_relation")
+
+ def read_state(self, state_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ return self._run_async(self.read_state_async(state_type, key), "cache_proxy.read_state")
+
+ def dump_all(self) -> Dict[str, Any]:
+ return self._run_async(self.dump_all_async(), "cache_proxy.dump_all")
+
+ def inspect(self) -> Dict[str, Any]:
+ return self._run_async(self.inspect_async(), "cache_proxy.inspect")
+
+ # === 异步实现 ===
+ async def read_entity_async(self, entity_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ types = self._resolve_types(entity_type, default=["services", "tools", "agents", "store", "clients"])
+ return await self._read_layer(types, key, layer="entity")
+
+ async def read_relation_async(self, relation_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ types = self._resolve_types(relation_type, default=["agent_services", "service_tools"])
+ return await self._read_layer(types, key, layer="relations")
+
+ async def read_state_async(self, state_type: Optional[Any] = None, key: Optional[str] = None) -> List[Dict[str, Any]]:
+ types = self._resolve_types(state_type, default=["service_status", "service_metadata"])
+ return await self._read_layer(types, key, layer="state")
+
+ async def dump_all_async(self) -> Dict[str, Any]:
+ entities, relations, states = await asyncio.gather(
+ self.read_entity_async(),
+ self.read_relation_async(),
+ self.read_state_async(),
+ )
+ return {
+ "entities": entities,
+ "relations": relations,
+ "states": states,
+ "metadata": {
+ "namespace": getattr(self._cache_layer, "_namespace", None),
+ "backend": self.get_backend_type(),
+ "scope": self.get_scope(),
+ "exported_at": datetime.utcnow().isoformat(),
+ },
+ }
+
+ async def inspect_async(self) -> Dict[str, Any]:
+ entities = await self.read_entity_async()
+ relations = await self.read_relation_async()
+ states = await self.read_state_async()
+
+ def _count_by_type(items: List[Dict[str, Any]]) -> Dict[str, int]:
+ counts: Dict[str, int] = {}
+ for item in items:
+ t = item.get("_type", "unknown")
+ counts[t] = counts.get(t, 0) + 1
+ return counts
+
+ return {
+ "backend": self.get_backend_type(),
+ "namespace": getattr(self._cache_layer, "_namespace", None),
+ "scope": self.get_scope(),
+ "counts": {
+ "entities": _count_by_type(entities),
+ "relations": _count_by_type(relations),
+ "states": _count_by_type(states),
+ },
+ "collections": sorted({item.get("_collection") for item in (entities + relations + states) if item.get("_collection")}),
+ "entities": entities,
+ "relations": relations,
+ "states": states,
+ }
+
+ # === 辅助方法 ===
+ def get_backend_type(self) -> str:
+ kv = getattr(self._cache_layer, "_kv_store", None)
+ return kv.__class__.__name__ if kv else "unknown"
+
+ def get_scope(self) -> str:
+ if self._scope == "global":
+ return "global"
+ if self._scope_value:
+ return f"{self._scope}:{self._scope_value}"
+ return self._scope
+
+ def _resolve_types(self, value: Optional[Any], default: List[str]) -> List[str]:
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, (list, tuple, set)):
+ return list(value)
+ return default
+
+ def _wrap_result(self, type_name: str, key: str, data: Dict[str, Any], collection: str) -> Dict[str, Any]:
+ wrapped = {"_key": key, "_type": type_name, "_collection": collection}
+ if isinstance(data, dict):
+ wrapped.update(data)
+ return wrapped
+
+ async def _await_safe(self, coro, op_name: str):
+ """
+ 在存在 AOB 时使用桥接执行,避免跨事件循环的 Future 绑定错误。
+ """
+ try:
+ if hasattr(self._context, "bridge_execute"):
+ return await self._context.bridge_execute(coro, op_name=op_name)
+ except Exception:
+ # fallback to direct await
+ pass
+ return await coro
+
+ def _apply_scope_filter(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ if self._scope == "global":
+ return items
+ target = self._scope_value
+ if self._scope == "agent":
+ return [it for it in items if it.get("agent_id") == target or it.get("_key", "").startswith(f"{target}")]
+ if self._scope == "service":
+ return [
+ it for it in items
+ if it.get("service_global_name") == target
+ or it.get("service_original_name") == target
+ or it.get("service_name") == target
+ or it.get("_key") == target
+ ]
+ if self._scope == "tool":
+ return [
+ it for it in items
+ if it.get("tool_global_name") == target
+ or it.get("tool_original_name") == target
+ or it.get("name") == target
+ or it.get("_key") == target
+ ]
+ return items
+
+ async def _read_layer(self, types: Sequence[str], key: Optional[str], layer: str) -> List[Dict[str, Any]]:
+ if not self._cache_layer:
+ logger.warning("[CACHE] Cache layer not available; returning empty list.")
+ return []
+
+ results: List[Dict[str, Any]] = []
+ logger.debug(
+ "[CACHE_PROXY] start read layer=%s types=%s key=%s scope=%s value=%s cache_ns=%s backend=%s",
+ layer,
+ list(types),
+ key,
+ self._scope,
+ self._scope_value,
+ getattr(self._cache_layer, "_namespace", None) if self._cache_layer else None,
+ getattr(getattr(self._cache_layer, "_kv_store", None), "__class__", type("X",(object,),{})).__name__ if self._cache_layer else None,
+ )
+ for t in types:
+ try:
+ if layer == "entity":
+ collection = self._cache_layer._get_entity_collection(t)
+ if key:
+ data = await self._await_safe(self._cache_layer.get_entity(t, key), f"cache.read_entity.{t}")
+ if data is not None:
+ results.append(self._wrap_result(t, key, data, collection))
+ else:
+ all_data = await self._await_safe(self._cache_layer.get_all_entities_async(t), f"cache.read_entities.{t}")
+ for k, v in all_data.items():
+ results.append(self._wrap_result(t, k, v, collection))
+ elif layer == "relations":
+ collection = self._cache_layer._get_relation_collection(t)
+ if key:
+ data = await self._await_safe(self._cache_layer.get_relation(t, key), f"cache.read_relation.{t}")
+ if data is not None:
+ results.append(self._wrap_result(t, key, data, collection))
+ else:
+ all_data = await self._await_safe(self._cache_layer.get_all_relations_async(t), f"cache.read_relations.{t}")
+ for k, v in all_data.items():
+ results.append(self._wrap_result(t, k, v, collection))
+ elif layer == "state":
+ collection = self._cache_layer._get_state_collection(t)
+ if key:
+ data = await self._await_safe(self._cache_layer.get_state(t, key), f"cache.read_state.{t}")
+ if data is not None:
+ results.append(self._wrap_result(t, key, data, collection))
+ else:
+ all_data = await self._await_safe(self._cache_layer.get_all_states_async(t), f"cache.read_states.{t}")
+ for k, v in all_data.items():
+ results.append(self._wrap_result(t, k, v, collection))
+ except Exception as e:
+ logger.warning(f"[CACHE] read_{layer} failed for type={t}, key={key}: {e}")
+
+ filtered = self._apply_scope_filter(results)
+ logger.debug(
+ "[CACHE_PROXY] done layer=%s count=%s filtered=%s scope=%s value=%s",
+ layer, len(results), len(filtered), self._scope, self._scope_value
+ )
+ return filtered
+
+ def _run_async(self, coro, op_name: str):
+ if self._bridge:
+ return self._bridge.run(coro, op_name=op_name)
+ return asyncio.run(coro)
+
+
+# 延迟导入用于类型检查
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from .base_context import MCPStoreContext
diff --git a/src/mcpstore/core/context/internal/context_kernel.py b/src/mcpstore/core/context/internal/context_kernel.py
new file mode 100644
index 00000000..bef57ae2
--- /dev/null
+++ b/src/mcpstore/core/context/internal/context_kernel.py
@@ -0,0 +1,62 @@
+"""
+ContextKernel abstraction (internal).
+
+Phase 1: minimal scaffold used by MCPStoreContext for read paths only (services/tools).
+No external API changes; callers still use MCPStoreContext methods.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..types import ContextType
+
+
+class ContextKernel:
+ """Kernel interface for context-specific operations."""
+
+ def list_services(self) -> Any: # returns List[ServiceInfo] or compatible
+ raise NotImplementedError
+
+ def list_tools(self) -> Any: # returns List[ToolInfo] or compatible
+ raise NotImplementedError
+
+
+class StoreContextKernel(ContextKernel):
+ def __init__(self, ctx: 'MCPStoreContext') -> None:
+ self.ctx = ctx
+
+ def list_services(self) -> Any:
+ # Delegate to store layer directly
+ return self.ctx._run_async_via_bridge(
+ self.ctx._store.list_services(),
+ op_name="context_kernel.store.list_services"
+ )
+
+ def list_tools(self) -> Any:
+ # Prefer orchestrator snapshot
+ return self.ctx.list_tools()
+
+
+class AgentContextKernel(ContextKernel):
+ def __init__(self, ctx: 'MCPStoreContext') -> None:
+ self.ctx = ctx
+
+ def list_services(self) -> Any:
+ # Keep existing agent-view logic
+ return self.ctx._run_async_via_bridge(
+ self.ctx._get_agent_service_view(),
+ op_name="context_kernel.agent.list_services"
+ )
+
+ def list_tools(self) -> Any:
+ # Keep existing agent-view logic
+ return self.ctx._run_async_via_bridge(
+ self.ctx._get_agent_tools_view(),
+ op_name="context_kernel.agent.list_tools"
+ )
+
+
+def create_kernel(ctx: 'MCPStoreContext') -> ContextKernel:
+ return StoreContextKernel(ctx) if ctx.context_type == ContextType.STORE else AgentContextKernel(ctx)
+
diff --git a/src/mcpstore/core/context/resources_prompts.py b/src/mcpstore/core/context/resources_prompts.py
new file mode 100644
index 00000000..538361aa
--- /dev/null
+++ b/src/mcpstore/core/context/resources_prompts.py
@@ -0,0 +1,309 @@
+"""
+MCPStore Resources and Prompts Module
+Implementation of Resources and Prompts functionality
+"""
+
+import logging
+from typing import Dict, Optional, Any
+
+from .types import ContextType
+
+logger = logging.getLogger(__name__)
+
+class ResourcesPromptsMixin:
+ """Resources and Prompts mixin class"""
+
+ def list_changed_tools(
+ self,
+ service_name: Optional[str] = None,
+ force_refresh: bool = False
+ ) -> Dict[str, Any]:
+ """
+ Tool change detection and processing method (synchronous version)
+
+ Supports hybrid tool change detection with FastMCP notification mechanism + polling backup strategy
+
+ Args:
+ service_name: Specific service name (optional, None means check all services)
+ force_refresh: Whether to force refresh (ignore cache and time intervals)
+
+ Returns:
+ Dict: Response containing change information
+ {
+ "changed": bool, # Whether there are changes
+ "services": List[str], # List of services with changes
+ "trigger": str, # Trigger method: "notification" | "polling" | "manual"
+ "timestamp": str, # Detection time
+ "details": Dict # Detailed change information
+ }
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return self._store.orchestrator.list_changed_tools(
+ service_name=service_name,
+ client_id=client_id,
+ force_refresh=force_refresh
+ )
+
+ async def list_changed_tools_async(
+ self,
+ service_name: Optional[str] = None,
+ force_refresh: bool = False
+ ) -> Dict[str, Any]:
+ """
+ 工具变更检测和处理方法(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选,None表示检查所有服务)
+ force_refresh: 是否强制刷新(忽略缓存和时间间隔)
+
+ Returns:
+ Dict: 包含变更信息的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.list_changed_tools_async(
+ service_name=service_name,
+ client_id=client_id,
+ force_refresh=force_refresh
+ )
+
+ def list_resources(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的资源(同步版本)
+
+ 支持列出静态资源和基于模板的动态资源
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的资源)
+
+ Returns:
+ Dict: 包含资源列表的响应
+ {
+ "success": bool, # 操作是否成功
+ "resources": List[Dict], # 资源列表
+ "service_name": str, # 服务名(如果指定)
+ "timestamp": str, # 操作时间
+ "resource_count": int # 资源数量
+ }
+ """
+ return self._run_async_via_bridge(
+ self.list_resources_async(service_name),
+ op_name="resources_prompts.list_resources"
+ )
+
+ async def list_resources_async(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的资源(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的资源)
+
+ Returns:
+ Dict: 包含资源列表的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.list_resources_async(
+ service_name=service_name,
+ client_id=client_id
+ )
+
+ def list_resource_templates(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的资源模板(同步版本)
+
+ 支持列出动态资源的模板信息
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的资源模板)
+
+ Returns:
+ Dict: 包含资源模板列表的响应
+ {
+ "success": bool, # 操作是否成功
+ "templates": List[Dict], # 资源模板列表
+ "service_name": str, # 服务名(如果指定)
+ "timestamp": str, # 操作时间
+ "template_count": int # 模板数量
+ }
+ """
+ return self._run_async_via_bridge(
+ self.list_resource_templates_async(service_name),
+ op_name="resources_prompts.list_resource_templates"
+ )
+
+ async def list_resource_templates_async(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的资源模板(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的资源模板)
+
+ Returns:
+ Dict: 包含资源模板列表的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.list_resource_templates_async(
+ service_name=service_name,
+ client_id=client_id
+ )
+
+ def read_resource(self, uri: str, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 读取资源内容(同步版本)
+
+ 支持读取静态资源和基于模板的动态资源
+
+ Args:
+ uri: 资源URI(如 "resource://config" 或 "weather://london/current")
+ service_name: 特定服务名(可选,None表示从所有服务中查找)
+
+ Returns:
+ Dict: 包含资源内容的响应
+ {
+ "success": bool, # 操作是否成功
+ "data": List[Dict], # 资源内容列表
+ "uri": str, # 资源URI
+ "service_name": str, # 提供资源的服务名
+ "timestamp": str, # 操作时间
+ "content_count": int # 内容块数量
+ }
+ """
+ return self._run_async_via_bridge(
+ self.read_resource_async(uri, service_name),
+ op_name="resources_prompts.read_resource"
+ )
+
+ async def read_resource_async(self, uri: str, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 读取资源内容(异步版本)
+
+ Args:
+ uri: 资源URI
+ service_name: 特定服务名(可选)
+
+ Returns:
+ Dict: 包含资源内容的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.read_resource_async(
+ uri=uri,
+ service_name=service_name,
+ client_id=client_id
+ )
+
+ def list_prompts(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的提示词(同步版本)
+
+ 支持列出所有可用的提示词模板
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的提示词)
+
+ Returns:
+ Dict: 包含提示词列表的响应
+ {
+ "success": bool, # 操作是否成功
+ "prompts": List[Dict], # 提示词列表
+ "service_name": str, # 服务名(如果指定)
+ "timestamp": str, # 操作时间
+ "prompt_count": int # 提示词数量
+ }
+ """
+ return self._run_async_via_bridge(
+ self.list_prompts_async(service_name),
+ op_name="resources_prompts.list_prompts"
+ )
+
+ async def list_prompts_async(self, service_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 列出可用的提示词(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选,None表示列出所有服务的提示词)
+
+ Returns:
+ Dict: 包含提示词列表的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.list_prompts_async(
+ service_name=service_name,
+ client_id=client_id
+ )
+
+ def get_prompt(
+ self,
+ name: str,
+ arguments: Optional[Dict[str, Any]] = None,
+ service_name: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 获取提示词内容(同步版本)
+
+ 支持获取带参数的动态提示词
+
+ Args:
+ name: 提示词名称
+ arguments: 提示词参数(可选)
+ service_name: 特定服务名(可选,None表示从所有服务中查找)
+
+ Returns:
+ Dict: 包含提示词内容的响应
+ {
+ "success": bool, # 操作是否成功
+ "prompt": Dict, # 提示词内容
+ "name": str, # 提示词名称
+ "service_name": str, # 提供提示词的服务名
+ "timestamp": str, # 操作时间
+ "arguments": Dict # 使用的参数
+ }
+ """
+ return self._run_async_via_bridge(
+ self.get_prompt_async(name, arguments, service_name),
+ op_name="resources_prompts.get_prompt"
+ )
+
+ async def get_prompt_async(
+ self,
+ name: str,
+ arguments: Optional[Dict[str, Any]] = None,
+ service_name: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 获取提示词内容(异步版本)
+
+ Args:
+ name: 提示词名称
+ arguments: 提示词参数(可选)
+ service_name: 特定服务名(可选)
+
+ Returns:
+ Dict: 包含提示词内容的响应
+ """
+ client_id = None
+ if self._context_type == ContextType.AGENT:
+ client_id = self._agent_id
+
+ return await self._store.orchestrator.get_prompt_async(
+ name=name,
+ arguments=arguments,
+ service_name=service_name,
+ client_id=client_id
+ )
diff --git a/src/mcpstore/core/context/service_management.py b/src/mcpstore/core/context/service_management.py
new file mode 100644
index 00000000..f3cf89d2
--- /dev/null
+++ b/src/mcpstore/core/context/service_management.py
@@ -0,0 +1,1786 @@
+"""
+MCPStore Service Management Module
+服务管理相关操作的实现
+"""
+
+import asyncio
+import logging
+import time
+from typing import Dict, List, Optional, Any, Union, Tuple
+
+from mcpstore.core.models.service import ServiceConnectionState
+from .types import ContextType
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateServiceAuthHelper:
+ """更新服务认证助手 - 明确的服务名,避免状态混乱
+
+ Note: 这是一个内部助手类,为了符合 async-only 约束,
+ 所有方法都改为 async,外部调用者需要 await。
+ """
+
+ def __init__(self, context: 'MCPStoreContext', service_name: str, config: Dict[str, Any] = None):
+ self._context = context
+ self._service_name = service_name # [CONFIG] Clear service name to avoid confusion
+ self._config = config.copy() if config else {}
+
+ async def bearer_auth(self, auth: str) -> 'MCPStoreContext':
+ """Update Bearer Token authentication for specified service (backward compatible)"""
+ # Standardize to Authorization header
+ if "headers" not in self._config:
+ self._config["headers"] = {}
+ self._config["headers"]["Authorization"] = f"Bearer {auth}"
+ return await self._execute_update()
+
+ async def token(self, token: str) -> 'MCPStoreContext':
+ """Recommended: Set Bearer Token (equivalent to bearer_auth)"""
+ if "headers" not in self._config:
+ self._config["headers"] = {}
+ self._config["headers"]["Authorization"] = f"Bearer {token}"
+ return await self._execute_update()
+
+ async def api_key(self, api_key: str) -> 'MCPStoreContext':
+ """Recommended: Set API Key (standardized to X-API-Key)"""
+ if "headers" not in self._config:
+ self._config["headers"] = {}
+ self._config["headers"]["X-API-Key"] = api_key
+ return await self._execute_update()
+
+ async def custom_headers(self, headers: Dict[str, str]) -> 'MCPStoreContext':
+ """Update custom headers for specified service (explicit override)"""
+ if "headers" not in self._config:
+ self._config["headers"] = {}
+ self._config["headers"].update(headers)
+ return await self._execute_update()
+
+ async def _execute_update(self) -> 'MCPStoreContext':
+ """执行更新服务(内部 async-only)"""
+ await self._context.update_service_async(self._service_name, self._config)
+ return self._context
+
+
+class ServiceManagementMixin:
+ """服务管理混入类"""
+
+ # [已删除] check_services 同步方法
+ # 根据 "pykv 唯一真相数据源" 原则,请使用 check_services_async 异步方法
+
+ async def check_services_async(self) -> dict:
+ """
+ 异步健康检查,store/agent上下文自动判断
+ - store上下文:聚合 global_agent_store 下所有 client_id 的服务健康状态
+ - agent上下文:聚合 agent_id 下所有 client_id 的服务健康状态
+ """
+ if self._context_type.name == 'STORE':
+ return await self._store.get_health_status()
+ elif self._context_type.name == 'AGENT':
+ return await self._store.get_health_status(self._agent_id, agent_mode=True)
+ else:
+ logger.error(f"[check_services] Unknown context type: {self._context_type}")
+ return {}
+
+ def get_service_info(self, name: str) -> Any:
+ """
+ 获取服务详情(同步版本),支持 store/agent 上下文
+ - store上下文:在 global_agent_store 下的所有 client 中查找服务
+ - agent上下文:在指定 agent_id 下的所有 client 中查找服务
+
+ [新架构] 避免_sync_helper.run_async,使用更安全的同步实现
+ """
+ try:
+ if not name:
+ return {}
+
+ if self._context_type == ContextType.STORE:
+ logger.debug(f"STORE mode - searching service in global_agent_store: {name}")
+ agent_id = self._store.client_manager.global_agent_store_id
+ else:
+ logger.debug(f"AGENT mode - searching service in agent({self._agent_id}): {name}")
+ agent_id = self._agent_id
+
+ # 直接从缓存获取服务信息
+ complete_info = self._store.registry.get_complete_service_info(agent_id, name)
+ if not complete_info:
+ logger.debug(f"Service {name} not found in agent {agent_id}")
+ return {}
+
+ # 构建返回信息
+ return {
+ "name": name,
+ "client_id": complete_info.get("client_id"),
+ "config": complete_info.get("config", {}),
+ "state": complete_info.get("state", "disconnected"),
+ "tool_count": complete_info.get("tool_count", 0),
+ "agent_id": agent_id
+ }
+
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] get_service_info failed: {e}")
+ return {
+ "name": name,
+ "error": str(e),
+ "agent_id": getattr(self, '_agent_id', 'unknown')
+ }
+
+ async def get_service_info_async(self, name: str) -> Any:
+ """
+ 获取服务详情(异步版本),支持 store/agent 上下文
+ - store上下文:在 global_agent_store 下的所有 client 中查找服务
+ - agent上下文:在指定 agent_id 下的所有 client 中查找服务(支持本地名称)
+ """
+ if not name:
+ return {}
+
+ if self._context_type == ContextType.STORE:
+ logger.debug(f"STORE mode - searching service in global_agent_store: {name}")
+ return await self._store.get_service_info(name)
+ elif self._context_type == ContextType.AGENT:
+ # Agent模式:将名称原样交给 Store 层处理,Store 负责本地名/全局名的鲁棒解析
+ logger.debug(f"AGENT mode - searching service in agent({self._agent_id}): {name}")
+ return await self._store.get_service_info(name, self._agent_id)
+ else:
+ logger.error(f"[get_service_info] Unknown context type: {self._context_type}")
+ return {}
+
+ def update_service(self,
+ name: str,
+ config: Union[Dict[str, Any], None] = None,
+ # 🆕 与用户用法对齐
+ auth: Optional[str] = None, # 兼容历史:等价于 token
+ token: Optional[str] = None, # 推荐:Bearer Token
+ api_key: Optional[str] = None, # 推荐:API Key
+ headers: Optional[Dict[str, str]] = None) -> Union['MCPStoreContext', 'UpdateServiceAuthHelper']:
+ """
+ 更新服务配置,支持安全的链式认证与凭证轮换(合并更新,不会破坏原有关键字段)
+
+ Args:
+ name: 服务名称(明确指定,不会混乱)
+ config: 新的服务配置(可选,按“补丁”合并语义处理)
+ auth/token: Bearer token(两者等价;优先使用 token)
+ api_key: API Key(统一标准化为 X-API-Key 头)
+ headers: 自定义请求头(显式传入的键优先级最高)
+
+ Returns:
+ 如果有配置或认证参数:立即执行更新,返回 MCPStoreContext
+ 如果什么都没有:返回 UpdateServiceAuthHelper 支持链式配置
+ """
+
+ if config is not None:
+ # 有配置参数:立即执行更新(与认证参数合并,并采用“补丁合并”语义)
+ if any([auth, token, api_key, headers]):
+ final_config = self._apply_auth_to_update_config(config, auth, token, api_key, headers)
+ else:
+ final_config = config
+
+ try:
+ self._run_async_via_bridge(
+ self.update_service_async(name, final_config),
+ op_name="service_management.update_service"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] update_service failed: {e}")
+ return self
+ else:
+ # 没有配置参数:
+ if any([auth, token, api_key, headers]):
+ # 纯认证:立即执行(也走补丁合并语义)
+ final_config = self._apply_auth_to_update_config({}, auth, token, api_key, headers)
+ try:
+ self._run_async_via_bridge(
+ self.update_service_async(name, final_config),
+ op_name="service_management.update_service_auth"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] update_service (auth) failed: {e}")
+ return self
+ else:
+ # 什么都没有:返回助手用于链式调用
+ return UpdateServiceAuthHelper(self, name, {})
+
+ async def update_service_async(self, name: str, config: Dict[str, Any]) -> bool:
+ """
+ 更新服务配置(异步版本)- 合并更新(不会破坏未提供的关键字段)
+
+ Args:
+ name: 服务名称
+ config: 新的服务配置(作为补丁)
+
+ Returns:
+ bool: 更新是否成功
+ """
+ try:
+ # 内部:简单的深度合并(仅对字典执行一层合并;headers 为字典则键级覆盖)
+ def _deep_merge(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
+ result = dict(base or {})
+ for k, v in (patch or {}).items():
+ if isinstance(v, dict) and isinstance(result.get(k), dict):
+ merged = dict(result.get(k) or {})
+ merged.update(v)
+ result[k] = merged
+ else:
+ result[k] = v
+ return result
+
+ if self._context_type == ContextType.STORE:
+ # Store级别:使用原子更新,避免读改写竞态
+ from mcpstore.core.configuration.config_write_service import ConfigWriteService
+ cws = ConfigWriteService()
+ def _mutator(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ servers = dict(cfg.get("mcpServers", {}))
+ if name not in servers:
+ raise KeyError(f"Service {name} not found in store configuration")
+ existing = dict(servers.get(name) or {})
+ merged = _deep_merge(existing, config)
+ servers[name] = merged
+ cfg["mcpServers"] = servers
+ return cfg
+ try:
+ success = cws.atomic_update(self._store.config.json_path, _mutator)
+ except KeyError as e:
+ logger.error(str(e))
+ return False
+
+ if success:
+ # 触发重新注册
+ if hasattr(self._store.orchestrator, 'sync_manager') and self._store.orchestrator.sync_manager:
+ await self._store.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+
+ return success
+ else:
+ # Agent级别:与单一数据源模式对齐——直接更新 mcp.json 并触发同步
+ global_name = name
+ if self._service_mapper:
+ global_name = self._service_mapper.to_global_name(name)
+
+ from mcpstore.core.configuration.config_write_service import ConfigWriteService
+ cws = ConfigWriteService()
+ def _mutator(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ servers = dict(cfg.get("mcpServers", {}))
+ if global_name not in servers:
+ raise KeyError(f"Service {global_name} not found in store configuration (agent mode)")
+ existing = dict(servers.get(global_name) or {})
+ merged = _deep_merge(existing, config)
+ servers[global_name] = merged
+ cfg["mcpServers"] = servers
+ return cfg
+ try:
+ success = cws.atomic_update(self._store.config.json_path, _mutator)
+ except KeyError as e:
+ logger.error(str(e))
+ return False
+
+ if success and hasattr(self._store.orchestrator, 'sync_manager') and self._store.orchestrator.sync_manager:
+ await self._store.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+
+ # 更新缓存中的 metadata.service_config,确保一致性
+ try:
+ # 从 pykv 异步获取元数据
+ global_agent = self._store.client_manager.global_agent_store_id
+ metadata = await self._store.registry._service_state_service.get_service_metadata_async(global_agent, global_name)
+ if metadata:
+ # 将变更合并到缓存元数据中
+ metadata.service_config = _deep_merge(metadata.service_config or {}, config)
+ await self._store.registry.set_service_metadata_async(global_agent, global_name, metadata)
+ except Exception as e:
+ logger.error(f"Failed to update service metadata: {e}")
+ raise
+
+ return success
+ except Exception as e:
+ logger.error(f"Failed to update service {name}: {e}")
+ raise
+
+ def patch_service(self, name: str, updates: Dict[str, Any]) -> bool:
+ """
+ 增量更新服务配置(同步版本)- 推荐使用
+
+ Args:
+ name: 服务名称
+ updates: 要更新的配置项
+
+ Returns:
+ bool: 更新是否成功
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.patch_service_async(name, updates),
+ op_name="service_management.patch_service"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] patch_service failed: {e}")
+ return False
+
+ async def patch_service_async(self, name: str, updates: Dict[str, Any]) -> bool:
+ """
+ 增量更新服务配置(异步版本)- 推荐使用
+
+ Args:
+ name: 服务名称
+ updates: 要更新的配置项
+
+ Returns:
+ bool: 更新是否成功
+ """
+ try:
+ if self._context_type == ContextType.STORE:
+ # Store级别:使用原子增量更新
+ from mcpstore.core.configuration.config_write_service import ConfigWriteService
+ cws = ConfigWriteService()
+ def _mutator(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ servers = dict(cfg.get("mcpServers", {}))
+ if name not in servers:
+ raise KeyError(f"Service {name} not found in store configuration")
+ merged = dict(servers[name])
+ merged.update(updates)
+ servers[name] = merged
+ cfg["mcpServers"] = servers
+ return cfg
+ try:
+ success = cws.atomic_update(self._store.config.json_path, _mutator)
+ except KeyError as e:
+ logger.error(str(e))
+ return False
+
+ if success:
+ # 触发重新注册
+ if hasattr(self._store.orchestrator, 'sync_manager') and self._store.orchestrator.sync_manager:
+ await self._store.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+
+ return success
+ else:
+ # Agent级别:与单一数据源模式对齐——直接增量更新 mcp.json 并触发同步
+ global_name = name
+ if self._service_mapper:
+ global_name = self._service_mapper.to_global_name(name)
+ from mcpstore.core.configuration.config_write_service import ConfigWriteService
+ cws = ConfigWriteService()
+ def _mutator(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ servers = dict(cfg.get("mcpServers", {}))
+ if global_name not in servers:
+ raise KeyError(f"Service {global_name} not found in store configuration (agent mode)")
+ merged = dict(servers[global_name])
+ merged.update(updates)
+ servers[global_name] = merged
+ cfg["mcpServers"] = servers
+ return cfg
+ try:
+ success = cws.atomic_update(self._store.config.json_path, _mutator)
+ except KeyError as e:
+ logger.error(str(e))
+ return False
+
+ if success and hasattr(self._store.orchestrator, 'sync_manager') and self._store.orchestrator.sync_manager:
+ await self._store.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+
+ # 更新缓存中的 metadata.service_config,确保一致性
+ try:
+ # 从 pykv 异步获取元数据
+ global_agent = self._store.client_manager.global_agent_store_id
+ metadata = await self._store.registry._service_state_service.get_service_metadata_async(global_agent, global_name)
+ if metadata:
+ metadata.service_config.update(updates)
+ self._store.registry.set_service_metadata(global_agent, global_name, metadata)
+ except Exception as e:
+ logger.error(f"Failed to update service metadata: {e}")
+ raise
+
+ return success
+ except Exception as e:
+ logger.error(f"Failed to patch service {name}: {e}")
+ raise
+
+ def delete_service(self, name: str) -> bool:
+ """
+ 删除服务(同步版本)
+
+ Args:
+ name: 服务名称
+
+ Returns:
+ bool: 删除是否成功
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.delete_service_async(name),
+ op_name="service_management.delete_service"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] delete_service failed: {e}")
+ return False
+
+ async def delete_service_async(self, name: str) -> bool:
+ """
+ 删除服务(异步版本,透明代理)
+
+ Args:
+ name: 服务名称(Agent 模式下使用本地名称)
+
+ Returns:
+ bool: 删除是否成功
+ """
+ try:
+ if self._context_type == ContextType.STORE:
+ # Store级别:删除服务并触发双向同步
+ await self._delete_store_service_with_sync(name)
+ return True
+ else:
+ # Agent级别:透明代理删除
+ await self._delete_agent_service_with_sync(name)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to delete service {name}: {e}")
+ return False
+
+ def _normalize_agent_local_name(self, input_name: str) -> str:
+ """
+ 将输入的服务标识归一化为当前 Agent 的本地服务名。
+
+ 支持三种输入:
+ 1) 纯本地名(默认)
+ 2) 全局名格式:_byagent_
+ 3) 冒号分隔::
+
+ 若提供的 agent_id 与当前上下文不一致则抛出异常,避免误删。
+ """
+ if not input_name:
+ raise ValueError("service name is required")
+
+ from .agent_service_mapper import AgentServiceMapper
+
+ # 全局名格式
+ if AgentServiceMapper.is_any_agent_service(input_name):
+ parsed_agent, local_name = AgentServiceMapper.parse_agent_service_name(input_name)
+ if parsed_agent != self._agent_id:
+ raise ValueError(
+ f"输入服务归属的 agent_id={parsed_agent} 与当前 agent_id={self._agent_id} 不一致"
+ )
+ return local_name
+
+ # 冒号分隔格式
+ if ":" in input_name:
+ maybe_agent, maybe_local = input_name.split(":", 1)
+ if maybe_local and maybe_agent:
+ if maybe_agent != self._agent_id:
+ raise ValueError(
+ f"输入服务归属的 agent_id={maybe_agent} 与当前 agent_id={self._agent_id} 不一致"
+ )
+ return maybe_local
+
+ # 默认当作本地名
+ return input_name
+
+ async def delete_service_two_step(self, service_name: str) -> Dict[str, Any]:
+ """
+ 两步删除服务:从配置文件删除 + 从Registry注销
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ Dict: 包含两步操作结果的字典
+ """
+ result = {
+ "step1_config_removal": False,
+ "step2_registry_cleanup": False,
+ "step1_error": None,
+ "step2_error": None,
+ "overall_success": False
+ }
+
+ # 第一步:从配置文件删除
+ try:
+ result["step1_config_removal"] = await self.delete_service_async(service_name)
+ if not result["step1_config_removal"]:
+ result["step1_error"] = "Failed to remove service from configuration"
+ except Exception as e:
+ result["step1_error"] = f"Configuration removal failed: {str(e)}"
+ logger.error(f"Step 1 (config removal) failed: {e}")
+
+ # 第二步:从Registry清理(即使第一步失败也尝试)
+ try:
+ if self._context_type == ContextType.STORE:
+ # Store级别:清理global_agent_store的Registry
+ cleanup_success = await self._store.orchestrator.registry.cleanup_service(service_name)
+ else:
+ # Agent级别:清理特定agent的Registry
+ global_name = service_name
+ if self._service_mapper:
+ global_name = self._service_mapper.to_global_name(service_name)
+ cleanup_success = await self._store.orchestrator.registry.cleanup_service(global_name, self._agent_id)
+
+ result["step2_registry_cleanup"] = cleanup_success
+ if not cleanup_success:
+ result["step2_error"] = "Failed to cleanup service from registry"
+ except Exception as e:
+ result["step2_error"] = f"Registry cleanup failed: {str(e)}"
+ logger.warning(f"Step 2 (registry cleanup) failed: {e}")
+
+ result["overall_success"] = result["step1_config_removal"] and result["step2_registry_cleanup"]
+ return result
+
+ def reset_config(self) -> bool:
+ """
+ 重置配置(同步版本)
+
+ 清空所有 pykv 缓存数据和 mcp.json 文件。
+ 相当于批量执行 delete_service 操作。
+ """
+ return self._run_async_via_bridge(
+ self.reset_config_async(),
+ op_name="service_management.reset_config"
+ )
+
+ def switch_cache(self, cache_config: Any) -> bool:
+ """运行时切换缓存后端(同步版本)。
+
+ 仅支持 Store 上下文;Agent 上下文会抛出 ValueError。
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.switch_cache_async(cache_config),
+ op_name="service_management.switch_cache"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] switch_cache failed: {e}")
+ return False
+
+ async def switch_cache_async(self, cache_config: Any) -> bool:
+ """运行时切换缓存后端(异步版本)。"""
+ try:
+ if self._context_type != ContextType.STORE:
+ raise ValueError("Cache switching is only supported in STORE context")
+
+ # 委托给 Store 层的封装方法,内部会进行配置解析和连接测试
+ await self._store._switch_cache_backend(cache_config)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to switch cache backend: {e}")
+ return False
+
+ async def reset_config_async(self) -> bool:
+ """
+ 重置配置(异步版本)
+
+ 清空所有 pykv 缓存数据和 mcp.json 文件。
+ 相当于批量执行 delete_service 操作。
+
+ 清理内容:
+ - pykv 实体层:services, tools
+ - pykv 关系层:agent_services, service_tools
+ - pykv 状态层:service_status, service_metadata
+ - mcp.json 文件
+ - 健康检查任务(通过服务不存在检测自动停止)
+
+ 根据上下文类型执行不同的重置操作:
+ - Store 上下文:清空所有 Agent 的配置
+ - Agent 上下文:只清空该 Agent 的配置
+ """
+ if self._context_type == ContextType.STORE:
+ return await self._reset_store_config()
+ else:
+ return await self._reset_agent_config()
+
+ async def _reset_store_config(self) -> bool:
+ """
+ Store 级别重置配置
+
+ 清理流程:
+ 1. 获取所有 Agent ID
+ 2. 对每个 Agent 调用 registry.clear_async()
+ - clear_async 内部调用 remove_service_async 逐个删除服务
+ - remove_service_async 清理:实体层、关系层、状态层、工具实体
+ 3. 重置 mcp.json 为空配置
+ """
+ logger.info("[RESET_CONFIG] [STORE] Store level: starting to reset all configurations")
+
+ # 1. 获取所有 Agent ID
+ agent_ids = await self._store.registry.get_all_agent_ids_async()
+ logger.debug(f"[RESET_CONFIG] [CLEAN] Found {len(agent_ids)} Agents need to be cleaned")
+
+ # 2. 清空每个 Agent 的缓存数据
+ for agent_id in agent_ids:
+ logger.debug(f"[RESET_CONFIG] [CLEAN] Cleaning Agent: {agent_id}")
+ await self._store.registry.clear_async(agent_id)
+
+ # 3. 重置 mcp.json 文件
+ default_config = {"mcpServers": {}}
+ mcp_success = self._store._unified_config.update_mcp_config(default_config)
+
+ logger.info("[RESET_CONFIG] [STORE] Store level: configuration reset completed")
+ return mcp_success
+
+ async def _reset_agent_config(self) -> bool:
+ """Agent级别重置配置的内部实现"""
+ try:
+ logger.info(f"[RESET_CONFIG] [AGENT] Agent level: resetting all configurations for Agent {self._agent_id}")
+
+ # 1. 清空Agent在缓存中的数据(使用异步版本)
+ await self._store.registry.clear_async(self._agent_id)
+
+ # 2. 单源模式:不再同步到分片文件
+ logger.info("Single-source mode: skip shard mapping files sync")
+
+ logger.info(f"[RESET_CONFIG] [AGENT] Agent level: Agent {self._agent_id} configuration reset completed")
+ return True
+
+ except Exception as e:
+ logger.error(f"[RESET_CONFIG] [ERROR] Agent level configuration reset failed: {e}")
+ return False
+
+ def show_config(self) -> Dict[str, Any]:
+ """
+ 显示配置信息(同步版本)
+
+ - Store级别: 返回所有Agent的配置
+ - Agent级别: 返回该Agent的配置
+
+ Returns:
+ Dict: 配置信息字典
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.show_config_async(),
+ op_name="service_management.show_config"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] show_config failed: {e}")
+ return {}
+
+ async def show_config_async(self) -> Dict[str, Any]:
+ """
+ 显示配置信息(异步版本)- 遵循 Functional Core, Imperative Shell 架构
+
+ 架构说明:
+ - 使用 ShowConfigAsyncShell 作为异步外壳,负责 pykv IO 操作
+ - 使用 ShowConfigLogicCore 作为纯逻辑核心,负责数据组装
+ - 严格遵循 pykv 唯一真相数据源原则
+
+ 根据上下文类型执行不同的显示操作:
+ - Store上下文:显示所有Agent的配置
+ - Agent上下文:显示该Agent的配置
+
+ Returns:
+ Dict: 配置信息字典
+ """
+ try:
+ # 获取 CacheLayerManager 实例
+ cache_layer = self._get_cache_layer_manager()
+
+ # 创建异步外壳实例
+ from mcpstore.core.architecture.show_config_shell import ShowConfigAsyncShell
+ shell = ShowConfigAsyncShell(cache_layer)
+
+ if self._context_type == ContextType.STORE:
+ return await shell.show_store_config_async()
+ else:
+ return await shell.show_agent_config_async(self._agent_id)
+
+ except Exception as e:
+ logger.error(f"Failed to show config: {e}")
+ # 使用纯逻辑核心构建错误响应
+ from mcpstore.core.architecture.show_config_core import ShowConfigLogicCore
+ logic_core = ShowConfigLogicCore()
+ return logic_core.build_error_response(
+ f"Failed to show config: {str(e)}",
+ agent_id=self._agent_id if self._context_type != ContextType.STORE else None
+ )
+
+ def _get_cache_layer_manager(self):
+ """
+ 获取 CacheLayerManager 实例
+
+ 遵循 pykv 唯一真相数据源原则,确保使用正确的缓存层管理器。
+
+ Returns:
+ CacheLayerManager 实例
+
+ Raises:
+ RuntimeError: 如果无法获取 CacheLayerManager
+ """
+ # 从 registry 获取 _cache_layer_manager
+ # 注意:不再使用 _cache_layer,因为它在 Redis 模式下是 RedisStore,没有所需的方法
+ cache_layer = getattr(self._store.registry, '_cache_layer_manager', None)
+ if cache_layer is not None:
+ return cache_layer
+
+ # 尝试从 store 获取
+ cache_layer = getattr(self._store, '_cache_layer_manager', None)
+ if cache_layer is not None:
+ return cache_layer
+
+ raise RuntimeError(
+ "无法获取 CacheLayerManager 实例。"
+ "请确保 MCPStore 已正确初始化,且 registry._cache_layer_manager 已设置。"
+ )
+
+ def delete_config(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ """
+ 删除服务配置(同步版本)
+
+ Args:
+ client_id_or_service_name: client_id或服务名
+
+ Returns:
+ Dict: 删除结果
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.delete_config_async(client_id_or_service_name),
+ op_name="service_management.delete_config"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] delete_config failed: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "client_id": None,
+ "service_name": None
+ }
+
+ async def delete_config_async(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ """
+ 删除服务配置(异步版本)
+
+ 支持智能参数识别:
+ - 如果传入client_id,直接使用
+ - 如果传入服务名,自动查找对应的client_id
+ - Agent级别严格隔离,只在指定agent范围内查找
+
+ Args:
+ client_id_or_service_name: client_id或服务名
+
+ Returns:
+ Dict: 删除结果
+ """
+ try:
+ if self._context_type == ContextType.STORE:
+ return await self._delete_store_config(client_id_or_service_name)
+ else:
+ return await self._delete_agent_config(client_id_or_service_name)
+ except Exception as e:
+ logger.error(f"Failed to delete config: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to delete config: {str(e)}",
+ "client_id": None,
+ "service_name": None
+ }
+
+ def update_config(self, client_id_or_service_name: str, new_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 更新服务配置(同步版本)
+
+ Args:
+ client_id_or_service_name: client_id或服务名
+ new_config: 新的配置信息
+
+ Returns:
+ Dict: 更新结果
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.update_config_async(client_id_or_service_name, new_config),
+ op_name="service_management.update_config"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] update_config failed: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "client_id": None,
+ "service_name": None,
+ "old_config": None,
+ "new_config": None
+ }
+
+ async def update_config_async(self, client_id_or_service_name: str, new_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 更新服务配置(异步版本)
+
+ 支持智能参数识别和多种配置格式:
+ - 参数识别:client_id或服务名自动识别
+ - 配置格式:支持简化格式和mcpServers格式
+ - 字段验证:不允许修改服务名,不允许新增字段类型
+ - Agent级别严格隔离
+
+ Args:
+ client_id_or_service_name: client_id或服务名
+ new_config: 新的配置信息
+
+ Returns:
+ Dict: 更新结果
+ """
+ try:
+ if self._context_type == ContextType.STORE:
+ return await self._update_store_config(client_id_or_service_name, new_config)
+ else:
+ return await self._update_agent_config(client_id_or_service_name, new_config)
+ except Exception as e:
+ logger.error(f"Failed to update config: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to update config: {str(e)}",
+ "client_id": None,
+ "service_name": None,
+ "old_config": None,
+ "new_config": None
+ }
+
+ def _is_deterministic_client_id(self, identifier: str) -> bool:
+ """使用 ClientIDGenerator 统一判断确定性client_id格式"""
+ try:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ return ClientIDGenerator.is_deterministic_format(identifier)
+ except Exception:
+ return False
+
+ def _parse_deterministic_client_id(self, client_id: str, agent_id: str) -> Tuple[str, str]:
+ """使用 ClientIDGenerator 统一解析确定性client_id,并验证agent范围"""
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ parsed = ClientIDGenerator.parse_client_id(client_id)
+ if parsed.get("type") == "store":
+ global_agent_store_id = self._store.client_manager.global_agent_store_id
+ if agent_id != global_agent_store_id:
+ raise ValueError(f"Store client_id '{client_id}' cannot be used with agent '{agent_id}'")
+ return client_id, parsed.get("service_name")
+ elif parsed.get("type") == "agent":
+ if parsed.get("agent_id") != agent_id:
+ raise ValueError(f"Client_id '{client_id}' belongs to agent '{parsed.get('agent_id')}', not '{agent_id}'")
+ return client_id, parsed.get("service_name")
+ raise ValueError(f"Cannot parse client_id format: {client_id}")
+
+ async def _validate_resolved_mapping_async(self, client_id: str, service_name: str, agent_id: str) -> bool:
+ """
+ 验证解析后的client_id和service_name映射是否有效(异步版本)
+
+ Args:
+ client_id: 解析出的client_id
+ service_name: 解析出的service_name
+ agent_id: Agent ID
+
+ Returns:
+ bool: 映射是否有效
+ """
+ try:
+ # 检查client_id是否存在于agent的映射中 - 从 pykv 获取
+ agent_clients = await self._store.registry.get_agent_clients_async(agent_id)
+ if client_id not in agent_clients:
+ logger.debug(f" [VALIDATE_MAPPING] client_id '{client_id}' not found in agent '{agent_id}' clients")
+ return False
+
+ # 检查service_name是否存在于Registry中
+ existing_client_id = await self._store.registry._agent_client_service.get_service_client_id_async(agent_id, service_name)
+ if existing_client_id != client_id:
+ logger.debug(f" [VALIDATE_MAPPING] service '{service_name}' maps to different client_id: expected={client_id}, actual={existing_client_id}")
+ return False
+
+ return True
+ except Exception as e:
+ logger.debug(f" [VALIDATE_MAPPING] Validation failed: {e}")
+ return False
+
+ def _validate_resolved_mapping(self, client_id: str, service_name: str, agent_id: str) -> bool:
+ raise RuntimeError("[SERVICE_MANAGEMENT] Synchronous validate_mapping is disabled, please use _validate_resolved_mapping_async.")
+
+ async def _resolve_client_id_async(self, client_id_or_service_name: str, agent_id: str) -> Tuple[str, str]:
+ """
+ 智能解析client_id或服务名(使用最新的确定性算法)
+
+ Args:
+ client_id_or_service_name: 用户输入的参数
+ agent_id: Agent ID(用于范围限制)
+
+ Returns:
+ Tuple[client_id, service_name]: 解析后的client_id和服务名
+
+ Raises:
+ ValueError: 当参数无法解析或不存在时
+ """
+ logger.debug(f"[RESOLVE_CLIENT_ID] start value='{client_id_or_service_name}' agent='{agent_id}'")
+
+ from .agent_service_mapper import AgentServiceMapper
+ global_agent_id = self._store.client_manager.global_agent_store_id
+
+ # 1) 优先:确定性 client_id 直接解析
+ if self._is_deterministic_client_id(client_id_or_service_name):
+ try:
+ client_id, service_name = self._parse_deterministic_client_id(client_id_or_service_name, agent_id)
+ logger.debug(f"[RESOLVE_CLIENT_ID] deterministic_ok client_id={client_id} service_name={service_name}")
+ return client_id, service_name
+ except ValueError as e:
+ logger.debug(f"[RESOLVE_CLIENT_ID] deterministic_parse_failed error={e}")
+ # 继续按服务名处理
+
+ # 2) Agent 模式:透明代理到 Store(不依赖 Agent 命名空间缓存)
+ if self._context_type == ContextType.AGENT and agent_id != global_agent_id:
+ # 2.1 判断输入是本地名还是全局名
+ input_name = client_id_or_service_name
+ global_service_name = None
+
+ if AgentServiceMapper.is_any_agent_service(input_name):
+ # 输入是全局名,校验归属
+ try:
+ parsed_agent_id, local_name = AgentServiceMapper.parse_agent_service_name(input_name)
+ if parsed_agent_id != agent_id:
+ raise ValueError(f"Service '{input_name}' belongs to agent '{parsed_agent_id}', not '{agent_id}'")
+ global_service_name = input_name
+ except ValueError as e:
+ raise ValueError(f"Invalid agent service name '{input_name}': {e}")
+ else:
+ # 输入是本地名:优先用映射,其次用规则推导
+ mapped = await self._store.registry.get_global_name_from_agent_service_async(agent_id, input_name)
+ global_service_name = mapped or AgentServiceMapper(agent_id).to_global_name(input_name)
+
+ # 2.2 优先在 Agent 命名空间解析 client_id,再回退到 Store 命名空间
+ client_id = await self._store.registry._agent_client_service.get_service_client_id_async(agent_id, input_name)
+ if not client_id:
+ # 回退到 Store 命名空间
+ client_id = await self._store.registry._agent_client_service.get_service_client_id_async(global_agent_id, global_service_name)
+
+ if not client_id:
+ available_agent = ', '.join(await self._store.registry.get_all_service_names_async(agent_id)) or 'None'
+ available_global = ', '.join(await self._store.registry.get_all_service_names_async(global_agent_id)) or 'None'
+ raise ValueError(
+ f"Service '{input_name}' (global '{global_service_name}') not found. "
+ f"Agent services: {available_agent}. Store services: {available_global}"
+ )
+
+ logger.debug(f"[RESOLVE_CLIENT_ID] agent_proxy_ok local_or_global='{input_name}' -> global='{global_service_name}' client_id={client_id}")
+ return client_id, global_service_name
+
+ # 3) Store 模式:直接在 Store 命名空间解析
+ service_name = client_id_or_service_name
+ service_names = await self._store.registry.get_all_service_names_async(agent_id)
+ if service_name in service_names:
+ client_id = await self._store.registry._agent_client_service.get_service_client_id_async(agent_id, service_name)
+ if client_id:
+ logger.debug(f"[RESOLVE_CLIENT_ID] store_lookup_ok service={service_name} client_id={client_id}")
+ return client_id, service_name
+ else:
+ raise ValueError(f"Service '{service_name}' found but no client_id mapping")
+
+ available_services = ', '.join(service_names) if service_names else 'None'
+ raise ValueError(f"Service '{service_name}' not found in store. Available services: {available_services}")
+
+ def _resolve_client_id(self, client_id_or_service_name: str, agent_id: str) -> Tuple[str, str]:
+ """
+ 同步包装,保留给旧代码使用;内部通过 AOB 执行异步解析。
+ """
+ return self._run_async_via_bridge(
+ self._resolve_client_id_async(client_id_or_service_name, agent_id),
+ op_name="service_management.resolve_client_id"
+ )
+
+ async def _delete_store_config(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ """Store级别删除配置的内部实现"""
+ try:
+ logger.info(f"[DELETE_CONFIG] [STORE] Store level: deleting configuration {client_id_or_service_name}")
+
+ global_agent_store_id = self._store.client_manager.global_agent_store_id
+
+ # 解析client_id和服务名
+ client_id, service_name = await self._resolve_client_id_async(client_id_or_service_name, global_agent_store_id)
+
+ logger.info(f"[DELETE_CONFIG] [RESOLVE] Resolution result: client_id={client_id}, service_name={service_name}")
+
+ # 验证服务存在
+ if not self._store.registry.get_session(global_agent_store_id, service_name):
+ logger.warning(f"Service {service_name} not found in registry, but continuing with cleanup")
+
+ # 事务性删除:先删除文件配置,再删除缓存
+ # 1. 从mcp.json中删除服务配置(使用 UnifiedConfigManager 自动刷新缓存)
+ success = self._store._unified_config.remove_service_config(service_name)
+ if success:
+ logger.info(f"[DELETE_CONFIG] [SUCCESS] Service removed from mcp.json: {service_name}, cache synchronized")
+
+ # 2. 从缓存中删除服务(包括工具和会话)- 使用异步版本
+ await self._store.registry.remove_service_async(global_agent_store_id, service_name)
+
+ # 3. 删除Service-Client映射
+ self._store.registry.remove_service_client_mapping(global_agent_store_id, service_name)
+
+ # 4. 删除Client配置
+ self._store.registry.remove_client_config(client_id)
+
+ # 5. 删除Agent-Client映射
+ self._store.registry.remove_agent_client_mapping(global_agent_store_id, client_id)
+
+ # 6. 单源模式:不再同步到分片文件
+ logger.info("Single-source mode: skip shard mapping files sync")
+
+ logger.info(f"[DELETE_CONFIG] [STORE] Store level: configuration deletion completed {service_name}")
+
+ return {
+ "success": True,
+ "message": f"Service '{service_name}' deleted successfully",
+ "client_id": client_id,
+ "service_name": service_name
+ }
+
+ except Exception as e:
+ logger.error(f"[DELETE_CONFIG] [ERROR] Store level configuration deletion failed: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to delete store config: {str(e)}",
+ "client_id": None,
+ "service_name": None
+ }
+
+ async def _delete_agent_config(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ """Agent级别删除配置的内部实现"""
+ try:
+ logger.info(f"[DELETE_CONFIG] [AGENT] Agent level: deleting Agent {self._agent_id} configuration {client_id_or_service_name}")
+
+ # 解析client_id和服务名
+ client_id, service_name = await self._resolve_client_id_async(client_id_or_service_name, self._agent_id)
+
+ logger.info(f"[DELETE_CONFIG] [RESOLVE] Resolution result: client_id={client_id}, service_name={service_name}")
+
+ # 验证服务存在
+ if not self._store.registry.get_session(self._agent_id, service_name):
+ logger.warning(f"Service {service_name} not found in registry for agent {self._agent_id}, but continuing with cleanup")
+
+ # Agent级别删除:只删除缓存,不修改mcp.json
+ # 1. 从缓存中删除服务(包括工具和会话)- 使用异步版本
+ await self._store.registry.remove_service_async(self._agent_id, service_name)
+
+ # 2. 删除Service-Client映射
+ self._store.registry.remove_service_client_mapping(self._agent_id, service_name)
+
+ # 3. 删除Client配置
+ self._store.registry.remove_client_config(client_id)
+
+ # 4. 删除Agent-Client映射
+ self._store.registry.remove_agent_client_mapping(self._agent_id, client_id)
+
+ # 5. 单源模式:不再同步到分片文件
+ logger.info("Single-source mode: skip shard mapping files sync")
+
+ logger.info(f"[DELETE_CONFIG] [AGENT] Agent level: configuration deletion completed {service_name}")
+
+ return {
+ "success": True,
+ "message": f"Service '{service_name}' deleted successfully from agent '{self._agent_id}'",
+ "client_id": client_id,
+ "service_name": service_name
+ }
+
+ except Exception as e:
+ logger.error(f"[DELETE_CONFIG] [ERROR] Agent level configuration deletion failed: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to delete agent config: {str(e)}",
+ "client_id": None,
+ "service_name": None
+ }
+
+ def _validate_and_normalize_config(self, new_config: Dict[str, Any], service_name: str, old_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 验证和标准化配置
+
+ Args:
+ new_config: 新配置
+ service_name: 服务名
+ old_config: 原配置
+
+ Returns:
+ Dict: 标准化后的配置
+
+ Raises:
+ ValueError: 配置验证失败
+ """
+ # 1. 处理配置格式
+ if "mcpServers" in new_config:
+ # mcpServers格式
+ if len(new_config["mcpServers"]) != 1:
+ raise ValueError("mcpServers format must contain exactly one service")
+
+ config_service_name = list(new_config["mcpServers"].keys())[0]
+ if config_service_name != service_name:
+ raise ValueError(f"Cannot change service name from '{service_name}' to '{config_service_name}'")
+
+ normalized_config = new_config["mcpServers"][service_name]
+ else:
+ # 简化格式
+ if "name" in new_config:
+ raise ValueError("Cannot modify service name in config update")
+ normalized_config = new_config.copy()
+
+ # 2. 验证字段类型一致性
+ old_config_keys = set(old_config.keys())
+ new_config_keys = set(normalized_config.keys())
+
+ # 检查是否有新增的字段类型
+ new_fields = new_config_keys - old_config_keys
+ if new_fields:
+ raise ValueError(f"Cannot add new field types: {list(new_fields)}. Only existing fields can be updated.")
+
+ # 3. 验证字段值的合理性
+ for key, value in normalized_config.items():
+ if key in old_config:
+ old_type = type(old_config[key])
+ new_type = type(value)
+
+ # 允许的类型转换
+ if old_type != new_type:
+ # 允许字符串和数字之间的转换
+ if not ((old_type in [str, int, float] and new_type in [str, int, float]) or
+ (old_type == list and new_type == list)):
+ raise ValueError(f"Field '{key}' type mismatch: expected {old_type.__name__}, got {new_type.__name__}")
+
+ return normalized_config
+
+ async def _update_store_config(self, client_id_or_service_name: str, new_config: Dict[str, Any]) -> Dict[str, Any]:
+ """Store级别更新配置的内部实现"""
+ try:
+ logger.info(f"[UPDATE_CONFIG] [STORE] Store level: updating configuration {client_id_or_service_name}")
+
+ global_agent_store_id = self._store.client_manager.global_agent_store_id
+
+ # 解析client_id和服务名
+ client_id, service_name = await self._resolve_client_id_async(client_id_or_service_name, global_agent_store_id)
+
+ logger.info(f"[UPDATE_CONFIG] [RESOLVE] Resolution result: client_id={client_id}, service_name={service_name}")
+
+ # 获取当前配置
+ old_complete_info = await self._store.registry.get_complete_service_info_async(global_agent_store_id, service_name)
+ old_config = old_complete_info.get("config", {})
+
+ if not old_config:
+ raise ValueError(f"Service '{service_name}' configuration not found")
+
+ # 验证和标准化新配置
+ normalized_config = self._validate_and_normalize_config(new_config, service_name, old_config)
+
+ logger.info(f"[UPDATE_CONFIG] [VALIDATE] Configuration validation passed, starting update: {service_name}")
+
+ # 1. 清空服务的工具和会话数据
+ self._store.registry.clear_service_tools_only(global_agent_store_id, service_name)
+
+ # 2. 更新Client配置缓存
+ self._store.registry.update_client_config(client_id, {
+ "mcpServers": {service_name: normalized_config}
+ })
+
+ # 3. 设置服务状态为INITIALIZING并更新元数据
+ from mcpstore.core.models.service import ServiceConnectionState
+ await self._store.orchestrator.lifecycle_manager._transition_state(
+ agent_id=global_agent_store_id,
+ service_name=service_name,
+ new_state=ServiceConnectionState.INITIALIZING,
+ reason="config_updated",
+ source="ServiceManagement",
+ )
+
+ # 从 pykv 异步获取并更新服务元数据中的配置
+ metadata = await self._store.registry._service_state_service.get_service_metadata_async(global_agent_store_id, service_name)
+ if metadata:
+ metadata.service_config = normalized_config
+ metadata.consecutive_failures = 0
+ metadata.error_message = None
+ from datetime import datetime
+ metadata.state_entered_time = datetime.now()
+ self._store.registry.set_service_metadata(global_agent_store_id, service_name, metadata)
+
+ # 4. 更新mcp.json文件(使用 UnifiedConfigManager 自动刷新缓存)
+ success = self._store._unified_config.add_service_config(service_name, normalized_config)
+ if not success:
+ raise Exception(f"Failed to update service config for {service_name}")
+
+ # 5. 单源模式:不再同步到分片文件
+ logger.info("Single-source mode: skip shard mapping files sync")
+
+ # 6. 触发生命周期管理器重新初始化服务
+ await self._store.orchestrator.lifecycle_manager.initialize_service(
+ global_agent_store_id, service_name, normalized_config
+ )
+
+ logger.info(f"[UPDATE_CONFIG] [STORE] Store level: configuration update completed {service_name}")
+
+ return {
+ "success": True,
+ "message": f"Service '{service_name}' configuration updated successfully",
+ "client_id": client_id,
+ "service_name": service_name,
+ "old_config": old_config,
+ "new_config": normalized_config
+ }
+
+ except Exception as e:
+ logger.error(f"[UPDATE_CONFIG] [ERROR] Store level configuration update failed: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to update store config: {str(e)}",
+ "client_id": None,
+ "service_name": None,
+ "old_config": None,
+ "new_config": None
+ }
+
+ async def _update_agent_config(self, client_id_or_service_name: str, new_config: Dict[str, Any]) -> Dict[str, Any]:
+ """Agent级别更新配置的内部实现"""
+ try:
+ logger.info(f"[UPDATE_CONFIG] [AGENT] Agent level: updating Agent {self._agent_id} configuration {client_id_or_service_name}")
+
+ # 解析client_id和服务名
+ client_id, service_name = await self._resolve_client_id_async(client_id_or_service_name, self._agent_id)
+
+ logger.info(f"[UPDATE_CONFIG] [RESOLVE] Resolution result: client_id={client_id}, service_name={service_name}")
+
+ # 获取当前配置
+ old_complete_info = await self._store.registry.get_complete_service_info_async(self._agent_id, service_name)
+ old_config = old_complete_info.get("config", {})
+
+ if not old_config:
+ raise ValueError(f"Service '{service_name}' configuration not found")
+
+ # 验证和标准化新配置
+ normalized_config = self._validate_and_normalize_config(new_config, service_name, old_config)
+
+ logger.info(f"[UPDATE_CONFIG] [VALIDATE] Configuration validation passed, starting update: {service_name}")
+
+ # 1. 清空服务的工具和会话数据
+ self._store.registry.clear_service_tools_only(self._agent_id, service_name)
+
+ # 2. 更新Client配置缓存
+ self._store.registry.update_client_config(client_id, {
+ "mcpServers": {service_name: normalized_config}
+ })
+
+ # 3. 设置服务状态为INITIALIZING并更新元数据
+ from mcpstore.core.models.service import ServiceConnectionState
+ await self._store.orchestrator.lifecycle_manager._transition_state(
+ agent_id=self._agent_id,
+ service_name=service_name,
+ new_state=ServiceConnectionState.INITIALIZING,
+ reason="agent_config_updated",
+ source="ServiceManagement",
+ )
+
+ # 从 pykv 异步获取并更新服务元数据中的配置
+ metadata = await self._store.registry._service_state_service.get_service_metadata_async(self._agent_id, service_name)
+ if metadata:
+ metadata.service_config = normalized_config
+ metadata.consecutive_failures = 0
+ metadata.error_message = None
+ from datetime import datetime
+ metadata.state_entered_time = datetime.now()
+ self._store.registry.set_service_metadata(self._agent_id, service_name, metadata)
+
+ # 4. 单源模式:不再同步到分片文件(Agent级别不更新mcp.json)
+ logger.info("Single-source mode: skip shard mapping files sync")
+
+ # 5. 触发生命周期管理器重新初始化服务
+ await self._store.orchestrator.lifecycle_manager.initialize_service(
+ self._agent_id, service_name, normalized_config
+ )
+
+ logger.info(f"[UPDATE_CONFIG] [AGENT] Agent level: configuration update completed {service_name}")
+
+ return {
+ "success": True,
+ "message": f"Service '{service_name}' configuration updated successfully for agent '{self._agent_id}'",
+ "client_id": client_id,
+ "service_name": service_name,
+ "old_config": old_config,
+ "new_config": normalized_config
+ }
+
+ except Exception as e:
+ logger.error(f"[UPDATE_CONFIG] [ERROR] Agent level configuration update failed: {e}")
+ return {
+ "success": False,
+ "error": f"Failed to update agent config: {str(e)}",
+ "client_id": None,
+ "service_name": None,
+ "old_config": None,
+ "new_config": None
+ }
+
+ def get_service_status(self, name: str) -> dict:
+ """获取单个服务的状态信息(同步版本,内部桥接异步)。"""
+ try:
+ return self._run_async_via_bridge(
+ self.get_service_status_async(name),
+ op_name="service_management.get_service_status"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] get_service_status failed: {e}")
+ return {"status": "error", "error": str(e)}
+
+ async def get_service_status_async(self, name: str) -> dict:
+ """获取单个服务的状态信息"""
+ try:
+ if self._context_type == ContextType.STORE:
+ return await self._store.orchestrator.get_service_status_async(name)
+ else:
+ # Agent模式:转换服务名称
+ global_name = name
+ if self._service_mapper:
+ global_name = self._service_mapper.to_global_name(name)
+ # 透明代理:在全局命名空间查询状态
+ return await self._store.orchestrator.get_service_status_async(global_name)
+ except Exception as e:
+ logger.error(f"Failed to get service status for {name}: {e}")
+ return {"status": "error", "error": str(e)}
+
+ def restart_service(self, name: str) -> bool:
+ raise RuntimeError("[SERVICE_MANAGEMENT] Synchronous restart_service is disabled, please use restart_service_async.")
+
+ async def restart_service_async(self, name: str) -> bool:
+ """重启指定服务(透明代理)"""
+ try:
+ if self._context_type == ContextType.STORE:
+ return await self._store.orchestrator.restart_service(name)
+ else:
+ # Agent模式:透明代理 - 将本地服务名映射到全局服务名,并在全局命名空间执行重启
+ global_name = await self._map_agent_service_to_global(name)
+ global_agent = self._store.client_manager.global_agent_store_id
+ return await self._store.orchestrator.restart_service(global_name, global_agent)
+ except Exception as e:
+ logger.error(f"Failed to restart service {name}: {e}")
+ return False
+
+ # === Lifecycle-only disconnection (no config/registry deletion) ===
+ def disconnect_service(self, name: str, reason: str = "user_requested") -> bool:
+ raise RuntimeError("[SERVICE_MANAGEMENT] Synchronous disconnect_service is disabled, please use disconnect_service_async.")
+
+ async def disconnect_service_async(self, name: str, reason: str = "user_requested") -> bool:
+ """
+ 断开服务(异步版本)- 仅生命周期断链:不改配置/不删注册表。
+
+ Store 上下文:name 视为全局名;
+ Agent 上下文:自动将本地名映射为全局名后断开。
+ """
+ try:
+ global_agent_id = self._store.client_manager.global_agent_store_id
+ if self._context_type == ContextType.STORE:
+ global_name = name
+ else:
+ global_name = await self._map_agent_service_to_global(name)
+
+ # 调用生命周期管理器执行优雅断开
+ lm = self._store.orchestrator.lifecycle_manager
+ await lm.graceful_disconnect(global_agent_id, global_name, reason)
+
+ # 清空工具展示缓存(仅清工具,不删除服务实体)
+ try:
+ self._store.registry.clear_service_tools_only(global_agent_id, global_name)
+ except Exception:
+ pass
+ return True
+ except Exception as e:
+ logger.error(f"[DISCONNECT_SERVICE] Failed to disconnect '{name}': {e}")
+ return False
+
+ # === 新增:Agent 透明代理辅助方法 ===
+
+ async def _map_agent_service_to_global(self, local_name: str) -> str:
+ """
+ 将 Agent 的本地服务名映射到全局服务名
+
+ Args:
+ local_name: Agent 中的本地服务名
+
+ Returns:
+ str: 全局服务名
+ """
+ try:
+ if self._agent_id:
+ # 尝试从映射关系中获取全局名称(使用异步版本,避免 AOB 事件循环冲突)
+ global_name = await self._store.registry.get_global_name_from_agent_service_async(self._agent_id, local_name)
+ if global_name:
+ logger.debug(f" [SERVICE_PROXY] Service name mapping: {local_name} -> {global_name}")
+ return global_name
+
+ # 如果映射失败,可能是 Store 原生服务,直接返回
+ logger.debug(f" [SERVICE_PROXY] No mapping, using original name: {local_name}")
+ return local_name
+
+ except Exception as e:
+ logger.error(f" [SERVICE_PROXY] Service name mapping failed: {e}")
+ return local_name
+
+ async def _delete_store_service_with_sync(self, service_name: str):
+ """Store 服务删除(带双向同步)"""
+ try:
+ # 1. 从 Registry 中删除(使用异步版本)
+ await self._store.registry.remove_service_async(
+ self._store.client_manager.global_agent_store_id,
+ service_name
+ )
+
+ # 2. 从 mcp.json 中删除(使用 UnifiedConfigManager 自动刷新缓存)
+ success = self._store._unified_config.remove_service_config(service_name)
+
+ if success:
+ logger.info(f"[SERVICE_DELETE] [STORE] Store service deletion successful: {service_name}, cache synchronized")
+ else:
+ logger.error(f" [SERVICE_DELETE] Store service deletion failed: {service_name}")
+
+ # 3. 触发双向同步(如果是 Agent 服务)
+ if hasattr(self._store, 'bidirectional_sync_manager'):
+ await self._store.bidirectional_sync_manager.handle_service_deletion_with_sync(
+ self._store.client_manager.global_agent_store_id,
+ service_name
+ )
+
+ except Exception as e:
+ logger.error(f" [SERVICE_DELETE] Store service deletion failed {service_name}: {e}")
+ raise
+
+ async def _delete_agent_service_with_sync(self, local_name: str):
+ """Agent 服务删除(带双向同步),返回是否成功"""
+ try:
+ # 宽容输入:支持本地名、全局名或 "agent:service" 格式
+ local_name = self._normalize_agent_local_name(local_name)
+
+ success = True
+ # 1. 获取全局名称(使用异步版本,避免 AOB 事件循环冲突)
+ global_name = await self._store.registry.get_global_name_from_agent_service_async(self._agent_id, local_name)
+ if not global_name:
+ logger.warning(f" [SERVICE_DELETE] Mapping not found: {self._agent_id}:{local_name}")
+ return False
+
+ # 2. 从 Agent 缓存中删除(使用异步版本)
+ await self._store.registry.remove_service_async(self._agent_id, local_name)
+
+ # 3. 从 Store 缓存中删除(使用异步版本)
+ await self._store.registry.remove_service_async(
+ self._store.client_manager.global_agent_store_id,
+ global_name
+ )
+
+ # 4. 移除映射关系(仅映射表,不触发关系/状态删除)
+ await self._store.registry.remove_agent_service_mapping_async(self._agent_id, local_name)
+
+ # 5. 从 mcp.json 中删除(使用 UnifiedConfigManager 自动刷新缓存)
+ success = success and self._store._unified_config.remove_service_config(global_name)
+
+ if success:
+ logger.info(f"[SERVICE_DELETE] [AGENT] Agent service deletion successful: {local_name} -> {global_name}, cache synchronized")
+ else:
+ logger.error(f" [SERVICE_DELETE] Agent service deletion failed: {local_name} -> {global_name}")
+
+ # 6. 清理服务状态数据
+ try:
+ state_manager = self._store.registry._cache_state_manager
+ await state_manager.delete_service_status(global_name)
+ await state_manager.delete_service_metadata(global_name)
+ logger.info(
+ f"[SERVICE_DELETE] Service status cleanup successful: "
+ f"agent_id={self._agent_id}, service={local_name}, global_name={global_name}"
+ )
+ except Exception as cleanup_error:
+ logger.error(
+ f"[SERVICE_DELETE] Service status cleanup failed: "
+ f"agent_id={self._agent_id}, service={local_name}, error={cleanup_error}"
+ )
+ raise
+
+ # 7. 单源模式:不再同步到分片文件
+ logger.info("Single-source mode: skip shard mapping files sync")
+ return success
+
+ except Exception as e:
+ logger.error(f" [SERVICE_DELETE] Agent service deletion failed {self._agent_id}:{local_name}: {e}")
+ raise
+
+ def show_mcpconfig(self) -> Dict[str, Any]:
+ """
+ 根据当前上下文(store/agent)获取对应的配置信息
+
+ Returns:
+ Dict[str, Any]: Store上下文返回MCP JSON格式,Agent上下文返回client配置字典
+ """
+ if self._context_type == ContextType.STORE:
+ # Store上下文:返回MCP JSON格式的配置(从缓存读取,更高效)
+ try:
+ config = self._store._unified_config.get_mcp_config()
+ # 确保返回格式正确
+ if isinstance(config, dict) and 'mcpServers' in config:
+ return config
+ else:
+ logger.warning("Invalid MCP config format")
+ return {"mcpServers": {}}
+ except Exception as e:
+ logger.error(f"Failed to show MCP config: {e}")
+ return {"mcpServers": {}}
+ else:
+ # Agent上下文:返回所有相关client配置的字典
+ return self._run_async_via_bridge(
+ self._show_config_agent_async(),
+ op_name="service_management.show_config_agent"
+ )
+
+ async def _show_config_agent_async(self) -> Dict[str, Any]:
+ """Agent上下文的 show_config 异步实现"""
+ agent_id = self._agent_id
+ # 从 pykv 获取 client_ids
+ client_ids = await self._store.registry.get_agent_clients_async(agent_id)
+
+ # 获取每个client的配置
+ result = {}
+ for client_id in client_ids:
+ client_config = self._store.orchestrator.client_manager.get_client_config(client_id)
+ if client_config:
+ result[client_id] = client_config
+
+ return result
+
+ def wait_service(self, client_id_or_service_name: str,
+ status: Union[str, List[str]] = 'healthy',
+ timeout: float = 10.0,
+ raise_on_timeout: bool = False) -> bool:
+ """
+ 等待服务达到指定状态(同步版本,使用新架构避免死锁)。
+
+ Args:
+ client_id_or_service_name: client_id或服务名(智能识别)
+ status: 目标状态,可以是单个状态字符串或状态列表
+ timeout: 超时时间(秒),默认10秒
+ raise_on_timeout: 超时时是否抛出异常,默认False
+
+ Returns:
+ bool: 成功达到目标状态返回True,超时返回False
+
+ Raises:
+ TimeoutError: 当raise_on_timeout=True且超时时抛出
+ ValueError: 当参数无法解析时抛出
+ """
+ try:
+ # 解析服务名称(简化版,实际可能需要更复杂的解析逻辑)
+ service_name = self._extract_service_name_from_identifier(client_id_or_service_name)
+
+ # 使用新架构:同步外壳
+ if not hasattr(self, '_service_management_sync_shell'):
+ from ..architecture import ServiceManagementFactory
+ self._service_management_sync_shell, _, _ = ServiceManagementFactory.create_service_management(
+ self._store.registry,
+ self._store.orchestrator,
+ agent_id=self._agent_id or self._store.client_manager.global_agent_store_id
+ )
+
+ # 直接调用同步外壳,避免_sync_helper.run_async的复杂性
+ result = self._service_management_sync_shell.wait_service(service_name, timeout)
+
+ if not result and raise_on_timeout:
+ raise TimeoutError(f"Service {service_name} did not reach status {status} within {timeout} seconds")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] wait_service failed: {e}")
+ if raise_on_timeout:
+ raise
+ return False
+
+ async def wait_service_async(self, client_id_or_service_name: str,
+ status: Union[str, List[str]] = 'healthy',
+ timeout: float = 10.0,
+ raise_on_timeout: bool = False) -> bool:
+ """
+ 等待服务达到指定状态(异步版本)
+
+ Args:
+ client_id_or_service_name: client_id或服务名(智能识别)
+ status: 目标状态,可以是单个状态字符串或状态列表
+ timeout: 超时时间(秒),默认10秒
+ raise_on_timeout: 超时时是否抛出异常,默认False
+
+ Returns:
+ bool: 成功达到目标状态返回True,超时返回False
+
+ Raises:
+ TimeoutError: 当raise_on_timeout=True且超时时抛出
+ ValueError: 当参数无法解析时抛出
+ """
+ try:
+ # 解析参数
+ agent_scope = self._agent_id if self._context_type == ContextType.AGENT else self._store.client_manager.global_agent_store_id
+ client_id, service_name = await self._resolve_client_id_async(client_id_or_service_name, agent_scope)
+
+ # 在纯视图模式下,Agent 的状态查询统一使用全局命名空间
+ status_agent_key = self._store.client_manager.global_agent_store_id
+
+
+ # 诊断:解析后的作用域与标识
+ try:
+ logger.info(f"[WAIT_SERVICE] resolved agent_scope={agent_scope} client_id='{client_id}' service='{service_name}' status_agent_key={status_agent_key}")
+ except Exception:
+ pass
+
+ # 解析等待模式
+ change_mode = False
+ if isinstance(status, str) and status.lower() == 'change':
+ change_mode = True
+ logger.info(f"[WAIT_SERVICE] start mode=change service='{service_name}' timeout={timeout}s")
+ try:
+ initial_status = (await self._store.orchestrator.get_service_status_async(service_name, status_agent_key) or {}).get("status", "unknown")
+ except Exception as _e_init:
+ logger.debug(f"[WAIT_SERVICE] initial_status_error service='{service_name}' error={_e_init}")
+ initial_status = "unknown"
+ else:
+ # 规范化目标状态
+ target_statuses = self._normalize_target_statuses(status)
+ logger.info(f"[WAIT_SERVICE] start mode=target service='{service_name}' client_id='{client_id}' target={target_statuses} timeout={timeout}s")
+
+ start_time = time.time()
+ poll_interval = 0.2 # 200ms轮询间隔
+ prev_status = None
+ last_log = start_time
+
+ while True:
+ # 检查超时
+ elapsed = time.time() - start_time
+ if elapsed >= timeout:
+ if change_mode:
+ msg = f"[WAIT_SERVICE] timeout mode=change service='{service_name}' from='{initial_status}' elapsed={elapsed:.2f}s"
+ else:
+ msg = f"[WAIT_SERVICE] timeout mode=target service='{service_name}' target={target_statuses} last='{prev_status}' elapsed={elapsed:.2f}s"
+ logger.warning(msg)
+ if raise_on_timeout:
+ raise TimeoutError(msg)
+ return False
+
+ # 获取当前状态(先读一次缓存,随后在必要时读一次新缓存以防止竞态)
+ try:
+
+ status_dict = await self._store.orchestrator.get_service_status_async(service_name, status_agent_key) or {}
+ current_status = status_dict.get("status", "unknown")
+
+ # 仅在状态变化或每2秒节流一次打印
+ now = time.time()
+ if current_status != prev_status or (now - last_log) > 2.0:
+ logger.debug(f"[WAIT_SERVICE] status service='{service_name}' value='{current_status}'")
+ # 对比 orchestrator 与 registry 的状态及最近健康检查(节流打印)
+ try:
+ reg_state = await self._store.registry.get_service_state_async(status_agent_key, service_name)
+ meta = await self._store.registry._service_state_service.get_service_metadata_async(status_agent_key, service_name)
+ last_check_ts = meta.last_health_check.isoformat() if getattr(meta, 'last_health_check', None) else None
+ logger.debug(f"[WAIT_SERVICE] compare orchestrator='{current_status}' registry='{getattr(reg_state,'value',reg_state)}' last_check={last_check_ts}")
+ except Exception as e:
+ logger.debug(f"[WAIT_SERVICE] Failed to get metadata: {e}")
+
+ prev_status, last_log = current_status, now
+
+ if change_mode:
+ if current_status != initial_status:
+ logger.info(f"[WAIT_SERVICE] done mode=change service='{service_name}' from='{initial_status}' to='{current_status}' elapsed={elapsed:.2f}s")
+ return True
+ else:
+ # 检查是否达到目标状态
+ if current_status in target_statuses:
+ logger.info(f"[WAIT_SERVICE] done mode=target service='{service_name}' reached='{current_status}' elapsed={elapsed:.2f}s")
+ return True
+ except Exception as e:
+ # 降级到 debug,避免无意义刷屏
+ logger.debug(f"[WAIT_SERVICE] status_error service='{service_name}' error={e}")
+ # 继续轮询
+
+ # 等待下次轮询
+ await asyncio.sleep(poll_interval)
+
+ except ValueError as e:
+ logger.error(f"[WAIT_SERVICE] param_error error={e}")
+ raise
+ except Exception as e:
+ logger.error(f"[WAIT_SERVICE] unexpected_error error={e}")
+ if raise_on_timeout:
+ raise
+ return False
+
+ def _normalize_target_statuses(self, status: Union[str, List[str]]) -> List[str]:
+ """
+ 规范化目标状态参数
+
+ Args:
+ status: 状态参数,可以是字符串或列表
+
+ Returns:
+ List[str]: 规范化的状态列表
+
+ Raises:
+ ValueError: 当状态值无效时抛出
+ """
+ # 获取有效的状态值
+ valid_statuses = {state.value for state in ServiceConnectionState}
+
+ if isinstance(status, str):
+ target_statuses = [status]
+ elif isinstance(status, list):
+ target_statuses = status
+ else:
+ raise ValueError(f"Status must be string or list, got {type(status)}")
+
+ # 验证状态值
+ for s in target_statuses:
+ if s not in valid_statuses:
+ raise ValueError(f"Invalid status '{s}'. Valid statuses are: {sorted(valid_statuses)}")
+
+ return target_statuses
+
+ def _apply_auth_to_update_config(self, config: Dict[str, Any],
+ auth: Optional[str],
+ token: Optional[str],
+ api_key: Optional[str],
+ headers: Optional[Dict[str, str]]) -> Dict[str, Any]:
+ """将认证配置应用到更新配置中(标准化为 headers + 合并语义)"""
+ final_config = config.copy() if config else {}
+
+ # 构造标准化后的 headers
+ normalized_headers: Dict[str, str] = {}
+ eff_token = token if token else auth
+ if eff_token:
+ normalized_headers["Authorization"] = f"Bearer {eff_token}"
+ if api_key:
+ normalized_headers["X-API-Key"] = api_key
+ if headers:
+ normalized_headers.update(headers)
+
+ if normalized_headers:
+ existing = dict(final_config.get("headers", {}) or {})
+ existing.update(normalized_headers)
+ final_config["headers"] = existing
+
+ # 清理入口字段,避免持久化污染
+ for k in ("token", "api_key", "auth"):
+ if k in final_config:
+ try:
+ del final_config[k]
+ except Exception:
+ final_config.pop(k, None)
+
+ return final_config
+
+ def _extract_service_name_from_identifier(self, client_id_or_service_name: str) -> str:
+ """
+ 从标识符中提取服务名称(新架构辅助方法)
+
+ Args:
+ client_id_or_service_name: client_id或服务名
+
+ Returns:
+ str: 服务名称
+ """
+ if not isinstance(client_id_or_service_name, str):
+ raise ValueError(f"Identifier must be a string, actual type: {type(client_id_or_service_name)}")
+
+ # 如果包含client_id格式,提取服务名称
+ if "::" in client_id_or_service_name:
+ # global_agent_store::service_name 格式
+ parts = client_id_or_service_name.split("::", 1)
+ if len(parts) == 2:
+ return parts[1]
+
+ # 如果是client_id格式,提取服务名称
+ if client_id_or_service_name.startswith("client_"):
+ # client_global_agent_store_service_name 格式
+ parts = client_id_or_service_name.split("_", 3)
+ if len(parts) >= 4:
+ return parts[3]
+
+ # 直接返回作为服务名称
+ return client_id_or_service_name
diff --git a/src/mcpstore/core/context/service_operations.py b/src/mcpstore/core/context/service_operations.py
new file mode 100644
index 00000000..3fb49250
--- /dev/null
+++ b/src/mcpstore/core/context/service_operations.py
@@ -0,0 +1,1228 @@
+"""
+MCPStore Service Operations Module - Event-Driven Architecture
+Implementation of service-related operations using event-driven pattern
+"""
+
+import logging
+from typing import Dict, List, Optional, Any, Union, Tuple
+
+from mcpstore.core.models.service import ServiceInfo, ServiceConfigUnion
+from .types import ContextType
+
+logger = logging.getLogger(__name__)
+
+
+class AddServiceWaitStrategy:
+ """添加服务等待策略"""
+
+ def __init__(self):
+ # 不同服务类型的默认等待时间(毫秒)
+ self.default_timeouts = {
+ 'remote': 2000, # 远程服务2秒
+ 'local': 4000, # 本地服务4秒
+ }
+
+ def parse_wait_parameter(self, wait_param: Union[str, int, float]) -> Optional[float]:
+ """
+ 解析等待参数
+
+ Args:
+ wait_param: 等待参数,支持:
+ - "auto": 自动根据服务类型判断
+ - 数字: 毫秒数
+ - 字符串数字: 毫秒数
+
+ Returns:
+ float: 等待时间(秒),None表示需要自动判断
+ """
+ if wait_param == "auto":
+ return None # 表示需要自动判断
+
+ # 尝试解析为数字(毫秒)
+ try:
+ if isinstance(wait_param, str):
+ ms = float(wait_param)
+ else:
+ ms = float(wait_param)
+
+ # 转换为秒,最小100ms,最大30秒
+ seconds = max(0.1, min(30.0, ms / 1000.0))
+ return seconds
+
+ except (ValueError, TypeError):
+ logger.warning(f"Invalid wait parameter '{wait_param}', using auto mode")
+ return None
+
+ def get_service_wait_timeout(self, service_config: Dict[str, Any]) -> float:
+ """
+ 根据服务配置获取等待超时时间
+
+ Args:
+ service_config: 服务配置
+
+ Returns:
+ float: 等待时间(秒)
+ """
+ if self._is_remote_service(service_config):
+ return self.default_timeouts['remote'] / 1000.0 # 转换为秒
+ else:
+ return self.default_timeouts['local'] / 1000.0 # 转换为秒
+
+ def _is_remote_service(self, service_config: Dict[str, Any]) -> bool:
+ """判断是否为远程服务"""
+ return bool(service_config.get('url'))
+
+ def get_max_wait_timeout(self, services_config: Dict[str, Dict[str, Any]]) -> float:
+ """
+ 获取多个服务的最大等待时间
+
+ Args:
+ services_config: 服务配置字典
+
+ Returns:
+ float: 最大等待时间(秒)
+ """
+ if not services_config:
+ return 2.0 # 默认2秒
+
+ max_timeout = 0.0
+ for service_config in services_config.values():
+ timeout = self.get_service_wait_timeout(service_config)
+ max_timeout = max(max_timeout, timeout)
+
+ return max_timeout
+
+
+class ServiceOperationsMixin:
+ """
+ Service operations mixin class - Event-Driven Architecture
+
+ 职责:提供用户API,委托给应用服务
+ """
+
+ @staticmethod
+ def _find_mcp_servers_key(config: Dict[str, Any]) -> Optional[str]:
+ """
+ 查找 mcpServers 键(不区分大小写)
+
+ Args:
+ config: 配置字典
+
+ Returns:
+ Optional[str]: 找到的键名(原始大小写),如果没找到返回 None
+ """
+ if not isinstance(config, dict):
+ return None
+
+ for key in config.keys():
+ if key.lower() == "mcpservers":
+ return key
+ return None
+
+ @staticmethod
+ def _normalize_mcp_servers(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """
+ 标准化 mcpServers 配置(将键名统一为 "mcpServers")
+
+ Args:
+ config: 配置字典
+
+ Returns:
+ Optional[Dict[str, Any]]: 标准化后的配置,如果没有 mcpServers 键返回 None
+ """
+ key = ServiceOperationsMixin._find_mcp_servers_key(config)
+ if not key:
+ return None
+
+ # 如果已经是标准格式,直接返回
+ if key == "mcpServers":
+ return config
+
+ # 标准化为 mcpServers
+ standardized = {k: v for k, v in config.items() if k != key}
+ standardized["mcpServers"] = config[key]
+ return standardized
+
+ # === Core service interface ===
+ def list_services(self) -> List[ServiceInfo]:
+ """
+ List services (synchronous wrapper) - 始终桥接到异步实现
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.list_services_async(),
+ op_name="service_operations.list_services"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] [ERROR] list_services failed: {e}")
+ return []
+
+ async def list_services_async(self) -> List[ServiceInfo]:
+ """
+ List services (asynchronous version)
+ - store context: aggregate services from all client_ids under global_agent_store
+ - agent context: show only agent's services with local names (transparent proxy)
+ """
+ if self._context_type == ContextType.STORE:
+ result = await self._store.list_services()
+ try:
+ logger.info(f"[LIST_SERVICES] context=STORE count={len(result)}")
+ except Exception:
+ pass
+ return result
+ else:
+ # Agent mode: 透明代理 - 只显示属于该 Agent 的服务,使用本地名称
+ result = await self._get_agent_service_view()
+ try:
+ logger.info(f"[LIST_SERVICES] context=AGENT agent_id={self._agent_id} count={len(result)}")
+ except Exception:
+ pass
+ return result
+
+ def add_service(self,
+ config: Union[ServiceConfigUnion, Dict[str, Any], str, None] = None,
+ json_file: str = None,
+ auth: Optional[str] = None,
+ token: Optional[str] = None,
+ api_key: Optional[str] = None,
+ headers: Optional[Dict[str, str]] = None) -> 'MCPStoreContext':
+ """
+ 添加服务(同步入口,使用新架构避免死锁)。
+
+ - 使用Functional Core, Imperative Shell架构
+ - 完全避免_sync_helper.run_async和_sync_to_kv调用
+ - 接受:单服务配置字典/JSON字符串/包含 mcpServers 的字典
+ - 认证:token/api_key 会标准化为 headers 并仅以 headers 落盘
+ - 等待:不等待连接;请使用 wait_service(...) 单独控制
+ """
+ # 标准化认证(token/api_key/auth -> headers)
+ final_config = self._apply_auth_to_config(config, auth, token, api_key, headers)
+
+ # 处理json_file参数(可选)
+ if json_file is not None:
+ logger.info(f"[CONFIG] [READ] Reading configuration from JSON file: {json_file}")
+ try:
+ import json
+ import os
+
+ if not os.path.exists(json_file):
+ raise Exception(f"JSON file does not exist: {json_file}")
+
+ with open(json_file, 'r', encoding='utf-8') as f:
+ file_config = json.load(f)
+
+ logger.info(f"[CONFIG] [READ] Successfully read JSON file, configuration: {file_config}")
+
+ # 如果同时指定了config和json_file,优先使用json_file
+ if final_config is not None:
+ logger.warning("[CONFIG] [WARN] Both config and json_file parameters specified, will use json_file")
+
+ final_config = file_config
+
+ except Exception as e:
+ raise Exception(f"Failed to read JSON file: {e}")
+
+ # 支持 config 传入 JSON 字符串(单服务或 mcpServers/root 映射)
+ if isinstance(final_config, str):
+ try:
+ import json as _json
+ cfg = _json.loads(final_config)
+ final_config = cfg
+ except Exception:
+ raise Exception("config must be valid JSON when provided as a string")
+
+ # 使用新架构:同步外壳(需在方法作用域初始化,避免非字符串配置时缺失)
+ if not hasattr(self, '_service_management_sync_shell'):
+ from ..architecture import ServiceManagementFactory
+ self._service_management_sync_shell, _, _ = ServiceManagementFactory.create_service_management(
+ self._store.registry,
+ self._store.orchestrator,
+ agent_id=self._agent_id or self._store.client_manager.global_agent_store_id
+ )
+
+ # 直接调用同步外壳,完全避免_sync_helper.run_async
+ result = self._service_management_sync_shell.add_service(final_config)
+
+ logger.debug(f"[NEW_ARCH] [RESULT] add_service result: {result.get('success', False)}")
+ return self
+
+ def add_service_with_details(self, config: Union[Dict[str, Any], List[Dict[str, Any]], str] = None) -> Dict[str, Any]:
+ """
+ 添加服务并返回详细信息(同步版本)
+
+ Args:
+ config: 服务配置
+
+ Returns:
+ Dict: 包含添加结果的详细信息
+ """
+ try:
+ return self._run_async_via_bridge(
+ self.add_service_with_details_async(config),
+ op_name="service_operations.add_service_with_details"
+ )
+ except Exception as e:
+ logger.error(f"[NEW_ARCH] [ERROR] add_service_with_details failed: {e}")
+ return {
+ "success": False,
+ "added_services": [],
+ "failed_services": self._extract_service_names(config),
+ "service_details": {},
+ "total_services": 0,
+ "total_tools": 0,
+ "message": str(e)
+ }
+
+ async def add_service_with_details_async(self, config: Union[Dict[str, Any], List[Dict[str, Any]], str] = None) -> Dict[str, Any]:
+ """
+ 添加服务并返回详细信息(异步版本)
+
+ Args:
+ config: 服务配置
+
+ Returns:
+ Dict: 包含添加结果的详细信息
+ """
+ logger.debug(f"Adding service with config: {type(config).__name__}")
+
+ # 预处理配置
+ try:
+ processed_config = self._preprocess_service_config(config)
+ logger.debug(f"Config preprocessed successfully")
+ except ValueError as e:
+ logger.error(f"Config preprocessing failed: {e}")
+ return {
+ "success": False,
+ "added_services": [],
+ "failed_services": self._extract_service_names(config),
+ "service_details": {},
+ "total_services": 0,
+ "total_tools": 0,
+ "message": str(e)
+ }
+
+ # 添加服务
+ try:
+ logger.debug("Calling add_service_async")
+ result = await self.add_service_async(processed_config)
+ logger.debug(f"Service addition result: {result is not None}")
+ except Exception as e:
+ logger.error(f"Service addition failed: {e}")
+ return {
+ "success": False,
+ "added_services": [],
+ "failed_services": self._extract_service_names(config),
+ "service_details": {},
+ "total_services": 0,
+ "total_tools": 0,
+ "message": f"Service addition failed: {str(e)}"
+ }
+
+ if result is None:
+ logger.error("Service addition returned None")
+ return {
+ "success": False,
+ "added_services": [],
+ "failed_services": self._extract_service_names(config),
+ "service_details": {},
+ "total_services": 0,
+ "total_tools": 0,
+ "message": "Service addition failed"
+ }
+
+ # 获取添加后的详情
+ logger.debug("Retrieving updated services and tools list")
+ services = await self.list_services_async()
+ tools = await self.list_tools_async()
+ logger.debug(f"Current services: {len(services)}, tools: {len(tools)}")
+ logger.debug(f"Service names: {[getattr(s, 'name', 'unknown') for s in services]}")
+
+ # 分析添加结果
+ expected_service_names = self._extract_service_names(config)
+ logger.debug(f"Expected service names: {expected_service_names}")
+ added_services = []
+ service_details = {}
+
+ for service_name in expected_service_names:
+ service_info = next((s for s in services if getattr(s, "name", None) == service_name), None)
+ logger.debug(f"Service {service_name}: {'found' if service_info else 'not found'}")
+ if service_info:
+ added_services.append(service_name)
+ service_tools = [t for t in tools if getattr(t, "service_name", None) == service_name]
+ service_details[service_name] = {
+ "tools_count": len(service_tools),
+ "status": getattr(service_info, "status", "unknown")
+ }
+ logger.debug(f"Service {service_name} has {len(service_tools)} tools")
+
+ failed_services = [name for name in expected_service_names if name not in added_services]
+ success = len(added_services) > 0
+ total_tools = sum(details["tools_count"] for details in service_details.values())
+
+ logger.debug(f"Successfully added services: {added_services}")
+ logger.debug(f"Failed to add services: {failed_services}")
+
+ message = (
+ f"Successfully added {len(added_services)} service(s) with {total_tools} tools"
+ if success else
+ f"Failed to add services. Available services: {[getattr(s, 'name', 'unknown') for s in services]}"
+ )
+
+ return {
+ "success": success,
+ "added_services": added_services,
+ "failed_services": failed_services,
+ "service_details": service_details,
+ "total_services": len(added_services),
+ "total_tools": total_tools,
+ "message": message
+ }
+
+ def _preprocess_service_config(self, config: Union[Dict[str, Any], List[Dict[str, Any]], str] = None) -> Union[Dict[str, Any], List[Dict[str, Any]], str]:
+ """预处理服务配置"""
+ if not config:
+ return config
+
+ if isinstance(config, dict):
+ # 处理单个服务配置
+ # 兼容大小写不敏感的 mcpServers
+ normalized = self._normalize_mcp_servers(config)
+ if normalized:
+ # mcpServers格式,返回标准化后的配置
+ return normalized
+ else:
+ # 单个服务格式,进行验证和转换
+ processed = config.copy()
+
+ # 验证必需字段
+ if "name" not in processed:
+ raise ValueError("Service configuration missing name field")
+
+ # 验证互斥字段
+ if "url" in processed and "command" in processed:
+ raise ValueError("Cannot specify both url and command")
+
+ # 自动推断transport类型
+ if "url" in processed and "transport" not in processed:
+ url = processed["url"]
+ if "/sse" in url.lower():
+ processed["transport"] = "streamable_http"
+ else:
+ processed["transport"] = "streamable_http"
+
+ # 验证args格式
+ if "command" in processed and not isinstance(processed.get("args", []), list):
+ raise ValueError("Args must be a list")
+
+ return processed
+
+ return config
+
+ def _extract_service_names(self, config: Union[Dict[str, Any], List[Dict[str, Any]], str] = None) -> List[str]:
+ """从配置中提取服务名称"""
+ if not config:
+ return []
+
+ if isinstance(config, dict):
+ if "name" in config:
+ return [config["name"]]
+ else:
+ # 兼容大小写不敏感的 mcpServers
+ key = self._find_mcp_servers_key(config)
+ if key:
+ return list(config[key].keys())
+ elif isinstance(config, list):
+ return config
+
+ return []
+
+ async def add_service_async(self,
+ config: Union[ServiceConfigUnion, Dict[str, Any], List[Dict[str, Any]], str, None] = None,
+ json_file: str = None,
+ # 认证参数(可选;若上层已标准化可忽略)
+ auth: Optional[str] = None,
+ token: Optional[str] = None,
+ api_key: Optional[str] = None,
+ headers: Optional[Dict[str, str]] = None) -> 'MCPStoreContext':
+ """
+ 增强版的服务添加方法,支持多种配置格式:
+ 1. URL方式:
+ await add_service({
+ "name": "weather",
+ "url": "https://weather-api.example.com/mcp",
+ "transport": "streamable_http"
+ })
+
+ 2. 本地命令方式:
+ await add_service({
+ "name": "assistant",
+ "command": "python",
+ "args": ["./assistant_server.py"],
+ "env": {"DEBUG": "true"}
+ })
+
+ 3. MCPConfig字典方式:
+ await add_service({
+ "mcpServers": {
+ "weather": {
+ "url": "https://weather-api.example.com/mcp"
+ }
+ }
+ })
+
+ 4. 不再支持“服务名称列表方式”,请传入完整配置(字典列表)或 mcpServers 字典。
+
+ 5. 不再支持“无参数方式”的全量注册(初始化阶段已同步一次)。
+
+ 6. JSON文件方式:
+ await add_service(json_file="path/to/config.json") # 读取JSON文件作为配置
+
+ 所有新添加的服务都会同步到 mcp.json 配置文件中。
+
+ Args:
+ config: 服务配置(字典/JSON字符串/包含 mcpServers 的字典/字典列表)
+ json_file: JSON文件路径,如果指定则读取该文件作为配置
+ auth/token/api_key/headers: 认证参数,会被标准化为 headers 并仅以 headers 落盘
+
+ Returns:
+ MCPStoreContext: 返回自身实例以支持链式调用
+ """
+ try:
+ # 应用认证配置到服务配置中(token/api_key/auth -> headers)
+ config = self._apply_auth_to_config(config, auth, token, api_key, headers)
+
+
+ # 处理json_file参数(可选)
+ if json_file is not None:
+ logger.info(f"[CONFIG] [READ] Reading configuration from JSON file: {json_file}")
+ try:
+ import json
+ import os
+
+ if not os.path.exists(json_file):
+ raise Exception(f"JSON file does not exist: {json_file}")
+
+ with open(json_file, 'r', encoding='utf-8') as f:
+ file_config = json.load(f)
+
+ logger.info(f"[CONFIG] [READ] Successfully read JSON file, configuration: {file_config}")
+
+ # 如果同时指定了config和json_file,优先使用json_file
+ if config is not None:
+ logger.warning("[CONFIG] [WARN] Both config and json_file parameters specified, will use json_file")
+
+ config = file_config
+
+ except Exception as e:
+ raise Exception(f"Failed to read JSON file: {e}")
+
+ # 支持 config 传入 JSON 字符串(单服务或 mcpServers/root 映射)
+ if isinstance(config, str):
+ try:
+ import json as _json
+ cfg = _json.loads(config)
+ config = cfg
+ except Exception:
+ raise Exception("config must be valid JSON when provided as a string")
+
+ # 宽容 root 映射(无 mcpServers):{"svc": {"url"|"command"...}, ...}
+ # 兼容大小写不敏感的 mcpServers
+ if isinstance(config, dict) and not self._find_mcp_servers_key(config) and "name" not in config:
+ if config and all(isinstance(v, dict) and ("url" in v or "command" in v) for v in config.values()):
+ config = {"mcpServers": config}
+
+ # 必须提供配置
+ if config is None and json_file is None:
+ raise Exception("Service configuration must be provided (dict/JSON string or json_file)")
+
+ except Exception as e:
+ logger.error(f"[ADD_SERVICE] [ERROR] Parameter processing failed: {e}")
+ raise
+
+ try:
+ # 获取正确的 agent_id(Store级别使用global_agent_store作为agent_id)
+ agent_id = self._agent_id if self._context_type == ContextType.AGENT else self._store.orchestrator.client_manager.global_agent_store_id
+
+ # 新增:详细的注册开始日志(已移除 source 参数)
+ logger.info(f"[ADD_SERVICE] start")
+ logger.info(f"[ADD_SERVICE] config type={type(config)} content={config}")
+ logger.info(f"[ADD_SERVICE] context={self._context_type.name} agent_id={agent_id}")
+
+ # 处理不同的输入格式
+ if config is None:
+ # 不再支持空参数的全量同步;初始化阶段已同步一次
+ raise Exception("Service configuration must be provided (no longer supports empty parameter full sync)")
+
+ # 处理列表格式
+ elif isinstance(config, list):
+ if not config:
+ raise Exception("List is empty")
+
+ # 判断是服务名称列表还是服务配置列表
+ if all(isinstance(item, str) for item in config):
+ raise Exception("Service name list is not supported, please provide full configuration (dict list) or mcpServers dict")
+
+ elif all(isinstance(item, dict) for item in config):
+ # 批量服务配置列表
+ logger.info(f"[ADD_SERVICE] [BATCH] Batch service configuration registration, count: {len(config)}")
+
+ # 转换为MCPConfig格式
+ mcp_config = {"mcpServers": {}}
+ for service_config in config:
+ service_name = service_config.get("name")
+ if not service_name:
+ raise Exception("Service in batch configuration missing name field")
+ mcp_config["mcpServers"][service_name] = {
+ k: v for k, v in service_config.items() if k != "name"
+ }
+
+ # 将config设置为转换后的mcp_config,然后继续处理
+ config = mcp_config
+
+ else:
+ raise Exception("Inconsistent element types in list, must be all strings (service names) or all dicts (service configurations)")
+
+ # 处理字典格式的配置(包括从批量配置转换来的)
+ if isinstance(config, dict):
+ # ========== 事件驱动路径 ==========
+ # 将配置解析为 {service_name: service_config},逐个发布 ServiceAddRequested
+ services_to_add: Dict[str, Dict[str, Any]] = {}
+
+ # 兼容 mcpServers
+ key = self._find_mcp_servers_key(config)
+ if key:
+ if not isinstance(config[key], dict):
+ raise Exception("mcpServers must be a dictionary type")
+ services_to_add = {
+ name: svc_cfg for name, svc_cfg in config[key].items()
+ if isinstance(svc_cfg, dict)
+ }
+ # 单服务格式 {"name": "...", ...}
+ elif "name" in config and isinstance(config.get("name"), str):
+ svc_name = config["name"]
+ svc_cfg = {k: v for k, v in config.items() if k != "name"}
+ services_to_add = {svc_name: svc_cfg}
+ else:
+ # 兜底:视为 {service_name: {url/command...}}
+ services_to_add = {
+ name: svc_cfg for name, svc_cfg in config.items()
+ if isinstance(svc_cfg, dict) and ("url" in svc_cfg or "command" in svc_cfg)
+ }
+
+ if not services_to_add:
+ raise Exception("Unable to parse valid service configuration")
+
+ logger.info(f"[ADD_SERVICE_ASYNC] [EVENT] Event-driven service addition: {list(services_to_add.keys())}")
+
+ # 通过应用服务发布事件,统一走 ServiceAddRequested -> ... 链路
+ app_service = self._store.container.service_application_service
+ source_tag = "agent_context" if self._context_type == ContextType.AGENT else "store_context"
+ global_agent_id = self._store.client_manager.global_agent_store_id
+ is_agent_ctx = self._context_type == ContextType.AGENT
+
+ for svc_name, svc_cfg in services_to_add.items():
+ extra_kwargs = {}
+ if is_agent_ctx:
+ from .agent_service_mapper import AgentServiceMapper
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ mapper = AgentServiceMapper(agent_id)
+ global_name = mapper.to_global_name(svc_name)
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=svc_name,
+ service_config=svc_cfg,
+ global_agent_store_id=global_agent_id
+ )
+ extra_kwargs = {
+ "global_name": global_name,
+ "client_id": client_id,
+ "origin_agent_id": agent_id,
+ "origin_local_name": svc_name,
+ }
+ else:
+ extra_kwargs = {
+ "global_name": svc_name,
+ }
+
+ result = await app_service.add_service(
+ agent_id=agent_id,
+ service_name=svc_name,
+ service_config=svc_cfg,
+ wait_timeout=0.0,
+ source=source_tag,
+ **extra_kwargs,
+ )
+ if result and result.success:
+ logger.debug(f"[ADD_SERVICE_ASYNC] [EVENT] ServiceAddRequested published: {svc_name}")
+ else:
+ logger.warning(f"[ADD_SERVICE_ASYNC] [ERROR] Failed to publish ServiceAddRequested: {svc_name}, error={getattr(result, 'error_message', None)}")
+
+ return self
+
+ except Exception as e:
+ logger.error(f"[ADD_SERVICE] [ERROR] Service addition failed: {e}")
+ raise
+
+ async def _initialize_service_tool_status(
+ self,
+ agent_id: str,
+ service_name: str
+ ) -> None:
+ """
+ 初始化服务的工具状态(使用 StateManager)
+
+ Store 和 Agent 模式都需要调用此方法。
+ 所有工具默认状态为 "available"。
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Raises:
+ RuntimeError: 如果初始化失败
+ """
+ logger.debug(
+ f"[TOOL_STATUS_INIT] Starting tool status initialization: "
+ f"agent_id={agent_id}, service_name={service_name}"
+ )
+
+ # 1. 获取服务的全局名称
+ if self._context_type == ContextType.AGENT:
+ # Agent 模式:需要将本地服务名映射到全局服务名(使用异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ agent_id, service_name
+ )
+ if not service_global_name:
+ raise RuntimeError(
+ f"无法获取服务全局名称: agent_id={agent_id}, "
+ f"service_name={service_name}"
+ )
+ else:
+ # Store 模式:服务名就是全局名称
+ service_global_name = service_name
+
+ logger.debug(
+ f"[TOOL_STATUS_INIT] Service global name: "
+ f"service_name={service_name}, service_global_name={service_global_name}"
+ )
+
+ # 2. 从关系层获取服务的工具列表
+ state_manager = self._store.registry._cache_state_manager
+ relation_manager = self._store.registry._relation_manager
+
+ tool_relations = await relation_manager.get_service_tools(service_global_name)
+
+ if not tool_relations:
+ logger.warning(
+ f"[TOOL_STATUS_INIT] Service has no tools: "
+ f"service_global_name={service_global_name}"
+ )
+ # 即使没有工具,也要创建服务状态
+ tool_relations = []
+
+ # 3. 构建工具状态列表(所有工具默认 available)
+ tools_status = []
+ for tool_rel in tool_relations:
+ tool_global_name = tool_rel.get("tool_global_name")
+ tool_original_name = tool_rel.get("tool_original_name")
+
+ if not tool_global_name or not tool_original_name:
+ raise RuntimeError(
+ f"工具关系数据不完整: tool_rel={tool_rel}"
+ )
+
+ tools_status.append({
+ "tool_global_name": tool_global_name,
+ "tool_original_name": tool_original_name,
+ "status": "available"
+ })
+
+ # 4. 使用 StateManager 更新服务状态
+ await state_manager.update_service_status(
+ service_global_name=service_global_name,
+ health_status="initializing",
+ tools_status=tools_status
+ )
+
+ logger.info(
+ f"[TOOL_STATUS_INIT] Tool status initialization successful: "
+ f"service_global_name={service_global_name}, "
+ f"tools_count={len(tools_status)}"
+ )
+
+ async def _connect_and_update_cache(self, agent_id: str, service_name: str, service_config: Dict[str, Any]):
+ """异步连接服务并更新缓存状态"""
+ try:
+ # 🔗 新增:连接开始日志
+ logger.debug(f"Connecting to service: {service_name}")
+ logger.debug(f"Agent ID: {agent_id}")
+ logger.info(f"[CONNECT_SERVICE] [CALL] Calling orchestrator.connect_service")
+
+ # 修复:使用connect_service方法(现已修复ConfigProcessor问题)
+ try:
+ logger.info(f"[CONNECT_SERVICE] [CALL] Preparing to call connect_service, parameters: name={service_name}, agent_id={agent_id}")
+ logger.info(f"[CONNECT_SERVICE] service_config: {service_config}")
+
+ # 使用修复后的connect_service方法(现在会使用ConfigProcessor)
+ success, message = await self._store.orchestrator.connect_service(
+ service_name, service_config=service_config, agent_id=agent_id
+ )
+
+ logger.debug("Service connection completed")
+
+ except Exception as connect_error:
+ logger.error(f"[CONNECT_SERVICE] [ERROR] connect_service call exception: {connect_error}")
+ import traceback
+ logger.error(f"[CONNECT_SERVICE] [ERROR] Exception stack: {traceback.format_exc()}")
+ success, message = False, f"Connection call failed: {connect_error}"
+
+ # 🔗 新增:连接结果日志
+ logger.info(f"[CONNECT_SERVICE] [RESULT] Connection result: success={success}, message={message}")
+
+ if success:
+ logger.info(f"Service '{service_name}' connected successfully")
+ # 连接成功,缓存会自动更新(通过现有的连接逻辑)
+ else:
+ logger.warning(f" Service '{service_name}' connection failed: {message}")
+ # 将连接失败交给生命周期管理器处理(事件驱动)
+ try:
+ from mcpstore.core.events.service_events import ServiceConnectionFailed
+
+ bus = getattr(self._store.orchestrator, "event_bus", None)
+ if bus:
+ failed_event = ServiceConnectionFailed(
+ agent_id=agent_id,
+ service_name=service_name,
+ error_message=message or "",
+ error_type="connection_failed",
+ retry_count=0,
+ )
+ await bus.publish(failed_event, wait=True)
+ logger.debug(f"[CONNECT_SERVICE] Published ServiceConnectionFailed for '{service_name}'")
+ else:
+ logger.warning("[CONNECT_SERVICE] EventBus not available; cannot publish ServiceConnectionFailed")
+ except Exception as event_err:
+ logger.warning(f"[CONNECT_SERVICE] Failed to publish ServiceConnectionFailed: {event_err}")
+
+ except Exception as e:
+ logger.error(f"[CONNECT_SERVICE] [ERROR] Exception occurred during entire connection process: {e}")
+ import traceback
+ logger.error(f"[CONNECT_SERVICE] [ERROR] Exception stack: {traceback.format_exc()}")
+
+ # 通过事件驱动方式通知生命周期管理器异常结果
+ try:
+ from mcpstore.core.events.service_events import ServiceConnectionFailed
+
+ bus = getattr(self._store.orchestrator, "event_bus", None)
+ if bus:
+ failed_event = ServiceConnectionFailed(
+ agent_id=agent_id,
+ service_name=service_name,
+ error_message=str(e),
+ error_type="connection_exception",
+ retry_count=0,
+ )
+ await bus.publish(failed_event, wait=True)
+ logger.error(f"[CONNECT_SERVICE] Published ServiceConnectionFailed after exception for '{service_name}'")
+ else:
+ logger.warning("[CONNECT_SERVICE] EventBus not available; cannot publish ServiceConnectionFailed after exception")
+ except Exception as event_err:
+ logger.warning(f"[CONNECT_SERVICE] Failed to publish ServiceConnectionFailed after exception: {event_err}")
+
+ # === Service Initialization Methods ===
+
+ def init_service(self, client_id_or_service_name: str = None, *,
+ client_id: str = None, service_name: str = None) -> 'MCPStoreContext':
+ raise RuntimeError("[SERVICE_OPERATIONS] Synchronous init_service is disabled, please use init_service_async.")
+
+ async def init_service_async(self, client_id_or_service_name: str = None, *,
+ client_id: str = None, service_name: str = None) -> 'MCPStoreContext':
+ """异步版本的服务初始化"""
+ try:
+ # 1. 参数验证和标准化
+ identifier = self._validate_and_normalize_init_params(
+ client_id_or_service_name, client_id, service_name
+ )
+
+ # 2. 根据上下文类型确定 agent_id
+ if self._context_type == ContextType.STORE:
+ agent_id = self._store.client_manager.global_agent_store_id
+ else:
+ agent_id = self._agent_id
+
+ # 3. 智能解析标识符(复用现有的完善逻辑)
+ resolved_client_id, resolved_service_name = await self._resolve_client_id_or_service_name_async(
+ identifier, agent_id
+ )
+
+ logger.info(f"[INIT_SERVICE] [RESOLVE] Resolution result: client_id={resolved_client_id}, service_name={resolved_service_name}")
+
+ # 4. 从缓存获取服务配置
+ service_config = await self._get_service_config_from_cache_async(agent_id, resolved_service_name)
+ if not service_config:
+ raise ValueError(f"Service configuration not found for {resolved_service_name}")
+
+ # 5. 调用生命周期管理器初始化服务(异步直接调用)
+ success = await self._store.orchestrator.lifecycle_manager.initialize_service(
+ agent_id=agent_id,
+ service_name=resolved_service_name,
+ service_config=service_config,
+ )
+
+ if not success:
+ raise RuntimeError(f"Failed to initialize service {resolved_service_name}")
+
+ logger.info(f" [INIT_SERVICE] Service {resolved_service_name} initialized to INITIALIZING state")
+ return self
+
+ except Exception as e:
+ logger.error(f" [INIT_SERVICE] Failed to initialize service: {e}")
+ raise
+
+ def _validate_and_normalize_init_params(self, client_id_or_service_name: str = None,
+ client_id: str = None, service_name: str = None) -> str:
+ """
+ 验证和标准化初始化参数
+
+ Args:
+ client_id_or_service_name: 通用标识符
+ client_id: 明确的client_id
+ service_name: 明确的service_name
+
+ Returns:
+ str: 标准化后的标识符
+
+ Raises:
+ ValueError: 参数验证失败时
+ """
+ # 统计非空参数数量
+ params = [client_id_or_service_name, client_id, service_name]
+ non_empty_params = [p for p in params if p is not None and p.strip()]
+
+ if len(non_empty_params) == 0:
+ raise ValueError("Must provide one of the following parameters: client_id_or_service_name, client_id, service_name")
+
+ if len(non_empty_params) > 1:
+ raise ValueError("Can only provide one parameter, cannot use multiple parameters simultaneously")
+
+ # 返回非空的参数
+ if client_id_or_service_name:
+ logger.debug(f"[INIT_PARAMS] [USE] Using generic parameter: {client_id_or_service_name}")
+ return client_id_or_service_name.strip()
+ elif client_id:
+ logger.debug(f"[INIT_PARAMS] [USE] Using explicit client_id: {client_id}")
+ return client_id.strip()
+ elif service_name:
+ logger.debug(f"[INIT_PARAMS] [USE] Using explicit service_name: {service_name}")
+ return service_name.strip()
+
+ # 理论上不会到达这里
+ raise ValueError("Parameter validation error")
+
+ def _resolve_client_id_or_service_name(self, client_id_or_service_name: str, agent_id: str) -> Tuple[str, str]:
+ """
+ 智能解析client_id或服务名(复用现有逻辑)
+
+ 直接复用 ServiceManagementMixin 中的 _resolve_client_id 方法
+ 确保解析逻辑的一致性
+
+ Args:
+ client_id_or_service_name: 用户输入的标识符
+ agent_id: Agent ID(用于范围限制)
+
+ Returns:
+ Tuple[str, str]: (client_id, service_name)
+
+ Raises:
+ ValueError: 当参数无法解析或不存在时
+ """
+ # 直接调用 ServiceManagementMixin 中的方法
+ return self._resolve_client_id(client_id_or_service_name, agent_id)
+
+ async def _resolve_client_id_or_service_name_async(self, client_id_or_service_name: str, agent_id: str) -> Tuple[str, str]:
+ """
+ 智能解析(异步版本),直接调用 ServiceManagementMixin 的异步实现。
+ """
+ return await self._resolve_client_id_async(client_id_or_service_name, agent_id)
+
+
+ async def _get_service_config_from_cache_async(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """从缓存获取服务配置(异步版本)"""
+ try:
+ # 方法1: 从 service_metadata 获取(优先)- 从 pykv 异步读取
+ metadata = await self._store.registry._service_state_service.get_service_metadata_async(agent_id, service_name)
+ if metadata and metadata.service_config:
+ logger.debug(f"[CONFIG] [GET] Getting configuration from metadata: {service_name}")
+ return metadata.service_config
+
+ # 方法2: 从服务实体获取(新架构:client 实体不再包含 mcpServers)
+ try:
+ service_info = await self._store.registry.get_complete_service_info_async(agent_id, service_name)
+ if service_info and service_info.get("config"):
+ logger.debug(f"[CONFIG] [GET] Getting configuration from service entity: {service_name}")
+ return service_info["config"]
+ except Exception as e:
+ logger.debug(f"[CONFIG] [ERROR] Unable to get configuration from service entity: {service_name}, {e}")
+
+ # 按要求:不兼容旧架构,直接抛出错误
+ raise RuntimeError(f"Service configuration not found: {service_name} (agent: {agent_id})")
+
+ except Exception as e:
+ logger.error(f"[CONFIG] [ERROR] Failed to get service configuration {service_name}: {e}")
+ return None
+
+ # === 新增:Agent 透明代理方法 ===
+
+ async def _add_agent_services_with_mapping(self, services_to_add: Dict[str, Any], agent_id: str):
+ """
+ Agent 服务添加的透明代理实现
+
+ 实现逻辑:
+ 1. 为每个服务生成全局名称(带后缀)
+ 2. 使用事件驱动在 global_agent_store 注册(全局名称)
+ 3. 建立 Agent ↔ 全局映射与 service-client 映射
+ 4. 生成共享 Client ID
+ 5. 同步全局名到 mcp.json
+ """
+ try:
+ logger.debug(f"Starting agent transparent proxy service addition for agent: {agent_id}")
+
+ from .agent_service_mapper import AgentServiceMapper
+ mapper = AgentServiceMapper(agent_id)
+ global_agent_id = self._store.client_manager.global_agent_store_id
+
+ global_services_for_file: Dict[str, Dict[str, Any]] = {}
+
+ for local_name, service_config in services_to_add.items():
+ logger.info(f"[AGENT_PROXY] [PROCESS] Processing service: {local_name}")
+
+ # 1. 生成全局名称
+ global_name = mapper.to_global_name(local_name)
+ logger.debug(f"[AGENT_PROXY] [MAP] Service name mapping: {local_name} -> {global_name}")
+
+ # 2. 生成共享 Client ID
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=local_name,
+ service_config=service_config,
+ global_agent_store_id=global_agent_id
+ )
+
+ # 3. 事件驱动注册到 global_agent_store(全局名)
+ result = await self._store.container.service_application_service.add_service(
+ agent_id=global_agent_id,
+ service_name=global_name,
+ service_config=service_config,
+ wait_timeout=0.0,
+ source="agent_context"
+ )
+ if not result or not result.success:
+ raise RuntimeError(f"Failed to add service (global) via event bus: {global_name}")
+
+ # 4. 建立 Agent ↔ 全局映射(直接使用关系管理器异步接口)
+ await self._store.registry._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=local_name,
+ service_global_name=global_name,
+ client_id=client_id
+ )
+
+ # 5. 设置 service-client 映射
+ await self._store.registry.set_service_client_mapping_async(agent_id, local_name, client_id)
+ await self._store.registry.set_service_client_mapping_async(global_agent_id, global_name, client_id)
+
+ # 6. 收集写入 mcp.json 的全局配置
+ global_services_for_file[global_name] = service_config
+
+ # 7. 同步到 mcp.json(全局名)
+ if global_services_for_file:
+ success = self._store._unified_config.batch_add_services(global_services_for_file)
+ if success:
+ logger.info(f"[AGENT_SYNC] [SUCCESS] mcp.json update successful: added {len(global_services_for_file)} services")
+ else:
+ logger.error(f"[AGENT_SYNC] [ERROR] mcp.json update failed")
+
+ logger.info(f"[AGENT_PROXY] [COMPLETE] Agent transparent proxy addition completed, processed {len(services_to_add)} services")
+
+ except Exception as e:
+ logger.error(f"[AGENT_PROXY] [ERROR] Agent transparent proxy addition failed: {e}")
+ raise
+
+ async def _sync_agent_services_to_files(self, agent_id: str, services_to_add: Dict[str, Any]):
+ """同步 Agent 服务到持久化文件(优化:使用 UnifiedConfigManager)"""
+ try:
+ logger.info(f"[AGENT_SYNC] [START] Starting to sync Agent services to file: {agent_id}")
+
+ # 构建带后缀的服务配置字典
+ from .agent_service_mapper import AgentServiceMapper
+ mapper = AgentServiceMapper(agent_id)
+
+ global_services = {}
+ for local_name, service_config in services_to_add.items():
+ global_name = mapper.to_global_name(local_name)
+ global_services[global_name] = service_config
+ logger.debug(f"[AGENT_SYNC] [PREPARE] Preparing to add to mcp.json: {global_name}")
+
+ # 使用 UnifiedConfigManager 批量添加服务(一次性保存 + 自动刷新缓存)
+ success = self._store._unified_config.batch_add_services(global_services)
+
+ if success:
+ logger.info(f"[AGENT_SYNC] [SUCCESS] mcp.json update successful: added {len(global_services)} services, cache synchronized")
+ else:
+ logger.error(f"[AGENT_SYNC] [ERROR] mcp.json update failed")
+
+ # 单源模式:不再写分片文件,仅维护 mcp.json
+ logger.info(f"[AGENT_SYNC] [INFO] Single-source mode: shard file writing disabled (agent_clients/client_services)")
+
+ except Exception as e:
+ logger.error(f"[AGENT_SYNC] [ERROR] Failed to sync Agent services to file: {e}")
+ raise
+
+ async def _get_agent_service_view(self) -> List[ServiceInfo]:
+ """
+ 获取 Agent 的服务视图(本地名称)
+
+ 透明代理(方案A):不读取 Agent 命名空间缓存,
+ 直接基于映射从 global_agent_store 的缓存派生服务列表。
+ """
+ try:
+ from mcpstore.core.models.service import ServiceInfo
+ from mcpstore.core.models.service import ServiceConnectionState
+
+ agent_services: List[ServiceInfo] = []
+ agent_id = self._agent_id
+ global_agent_id = self._store.client_manager.global_agent_store_id
+
+ # 1) 通过映射获取该 Agent 的全局服务名集合(使用异步接口,避免事件循环冲突)
+ global_service_names = await self._store.registry.get_agent_services_async(agent_id)
+ if not global_service_names:
+ logger.info(f"[AGENT_VIEW] [INFO] Agent {agent_id} service view: 0 services (no mapping)")
+ return agent_services
+
+ # 2) 遍历每个全局服务,从全局命名空间读取完整信息,并以本地名展示
+ for global_name in global_service_names:
+ # 解析出 (agent_id, local_name)
+ mapping = await self._store.registry.get_agent_service_from_global_name_async(global_name)
+ if not mapping:
+ continue
+ mapped_agent, local_name = mapping
+ if mapped_agent != agent_id:
+ continue
+
+ complete_info = await self._store.registry.get_complete_service_info_async(global_agent_id, global_name)
+ if not complete_info:
+ logger.debug(f"[AGENT_VIEW] [MISS] Service not found in global cache: {global_name}")
+ continue
+
+ # 状态转换
+ # 额外诊断:记录全局与Agent缓存的状态对比
+ try:
+ global_state_dbg = await self._store.registry._service_state_service.get_service_state_async(
+ global_agent_id, global_name
+ )
+ agent_state_dbg = await self._store.registry._service_state_service.get_service_state_async(
+ agent_id, local_name
+ )
+ logger.debug(f"[AGENT_VIEW] state_compare local='{local_name}' global='{global_name}' global_state='{getattr(global_state_dbg,'value',global_state_dbg)}' agent_state='{getattr(agent_state_dbg,'value',agent_state_dbg)}'")
+ except Exception:
+ pass
+
+ state = complete_info.get("state", ServiceConnectionState.DISCONNECTED)
+ if isinstance(state, str):
+ try:
+ state = ServiceConnectionState(state)
+ except Exception:
+ state = ServiceConnectionState.DISCONNECTED
+
+ cfg = complete_info.get("config", {})
+ tool_count = complete_info.get("tool_count", 0)
+
+ # 透明代理:client_id 使用全局命名空间的 client_id
+ service_info = ServiceInfo(
+ name=local_name,
+ status=state,
+ transport_type=self._store._infer_transport_type(cfg) if hasattr(self._store, '_infer_transport_type') else None,
+ url=cfg.get("url", "") if isinstance(cfg, dict) else "",
+ command=cfg.get("command") if isinstance(cfg, dict) else None,
+ args=cfg.get("args") if isinstance(cfg, dict) else None,
+ env=cfg.get("env") if isinstance(cfg, dict) else None,
+ working_dir=cfg.get("working_dir") if isinstance(cfg, dict) else None,
+ package_name=cfg.get("package_name") if isinstance(cfg, dict) else None,
+ client_id=complete_info.get("client_id"),
+ config=cfg,
+ tool_count=tool_count,
+ keep_alive=cfg.get("keep_alive", False),
+ )
+ agent_services.append(service_info)
+ logger.debug(f" [AGENT_VIEW] derive '{local_name}' <- '{global_name}' tools={tool_count}")
+
+ logger.info(f"[AGENT_VIEW] [INFO] Agent {agent_id} service view: {len(agent_services)} services (derived)")
+ return agent_services
+
+ except Exception as e:
+ logger.error(f"[AGENT_VIEW] [ERROR] Failed to get Agent service view: {e}")
+ return []
+
+ def _apply_auth_to_config(self, config,
+ auth: Optional[str],
+ token: Optional[str],
+ api_key: Optional[str],
+ headers: Optional[Dict[str, str]]):
+ """将认证配置应用到服务配置中(入口标准化)
+ - 将 token/auth 统一映射为 Authorization: Bearer
+ - 将 api_key 统一映射为 X-API-Key:
+ - headers 显式传入拥有最高优先级(覆盖前两者的相同键)
+ - 最终仅保留 headers 持久化,移除 token/api_key/auth 字段,避免混乱
+ """
+ # 如果没有任何认证参数,直接返回原配置
+ if auth is None and token is None and api_key is None and (not headers):
+ return config
+
+ # 构造标准化后的 headers
+ normalized_headers: Dict[str, str] = {}
+ # 兼容历史:auth 等价于 token(优先使用 token 覆盖 auth)
+ eff_token = token if token else auth
+ if eff_token:
+ normalized_headers.setdefault("Authorization", f"Bearer {eff_token}")
+ if api_key:
+ normalized_headers.setdefault("X-API-Key", api_key)
+ # 显式 headers 最高优先级
+ if headers:
+ normalized_headers.update(headers)
+
+ # 应用到配置(支持单服务字典或 mcpServers 结构)
+ def _apply_to_service_cfg(svc_cfg: Dict[str, Any]) -> Dict[str, Any]:
+ cfg = (svc_cfg or {}).copy()
+ # 合并 headers
+ existing = dict(cfg.get("headers", {}) or {})
+ existing.update(normalized_headers)
+ cfg["headers"] = existing
+ # 清理入口字段,避免落盘混乱
+ for k in ("token", "api_key", "auth"):
+ if k in cfg:
+ try:
+ del cfg[k]
+ except Exception:
+ cfg.pop(k, None)
+ return cfg
+
+ # 兼容大小写不敏感的 mcpServers
+ key = self._find_mcp_servers_key(config) if isinstance(config, dict) else None
+ if key and isinstance(config[key], dict):
+ final_config = {"mcpServers": {}}
+ for name, svc_cfg in config[key].items():
+ if isinstance(svc_cfg, dict):
+ final_config["mcpServers"][name] = _apply_to_service_cfg(svc_cfg)
+ else:
+ final_config["mcpServers"][name] = svc_cfg
+ return final_config
+ else:
+ # 单服务或其他可迭代形式
+ if isinstance(config, dict):
+ return _apply_to_service_cfg(config)
+ elif config is None:
+ return {"headers": normalized_headers}
+ else:
+ base = dict(config) if hasattr(config, "__iter__") and not isinstance(config, str) else {}
+ return _apply_to_service_cfg(base)
diff --git a/src/mcpstore/core/context/service_proxy.py b/src/mcpstore/core/context/service_proxy.py
new file mode 100644
index 00000000..bb740da3
--- /dev/null
+++ b/src/mcpstore/core/context/service_proxy.py
@@ -0,0 +1,441 @@
+"""
+MCPStore Service Proxy Module
+服务代理对象,提供具体服务的操作方法
+"""
+
+import logging
+from typing import Dict, List, Any
+
+from mcpstore.core.models.tool import ToolInfo
+from .types import ContextType
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceProxy:
+ """
+ 服务代理对象
+ 提供具体服务的所有操作方法,进一步缩小作用域
+ """
+
+ def __init__(
+ self,
+ context: 'MCPStoreContext',
+ service_name: str,
+ agent_id: str = None,
+ global_name: str = None
+ ):
+ """
+ 初始化服务代理
+
+ Args:
+ context: 父级上下文对象
+ service_name: 服务名称(本地名称)
+ agent_id: Agent ID(可选,用于绑定验证)
+ global_name: 全局服务名称(可选)
+ """
+ self._context = context
+ self._service_name = service_name
+ self._context_type = context.context_type
+ self._agent_id = agent_id or context.agent_id
+ self._global_name = global_name
+
+ # 验证绑定关系(如果提供了 agent_id)
+ if agent_id and self._context_type.value == "agent":
+ self._verify_binding()
+
+ logger.debug(f"[SERVICE_PROXY] Created proxy for service '{service_name}' in {self._context_type.value} context")
+
+ @property
+ def service_name(self) -> str:
+ """获取服务名称"""
+ return self._service_name
+
+ @property
+ def context_type(self) -> ContextType:
+ """获取上下文类型"""
+ return self._context_type
+
+ @property
+ def agent_id(self) -> str:
+ """获取绑定的 Agent ID"""
+ return self._agent_id
+
+ @property
+ def is_agent_scoped(self) -> bool:
+ """判断是否绑定到 Agent"""
+ return self._agent_id is not None and self._context_type.value == "agent"
+
+ def _verify_binding(self) -> None:
+ """验证服务绑定关系
+
+ Validates: Requirements 6.7, 6.8 (服务归属验证)
+ """
+ # 通过 Registry 验证服务映射是否存在
+ service_global_name = self._context._store.registry.get_global_name_from_agent_service(
+ self._agent_id,
+ self._service_name
+ )
+
+ if not service_global_name:
+ from mcpstore.core.exceptions import ServiceBindingError
+ raise ServiceBindingError(
+ service_name=self._service_name,
+ agent_id=self._agent_id,
+ reason="服务映射不存在"
+ )
+
+ logger.debug(
+ f"[SERVICE_PROXY] Verified binding for service '{self._service_name}' "
+ f"to agent '{self._agent_id}' (global_name={service_global_name})"
+ )
+
+ # === 服务信息查询方法(两个单词) ===
+
+ def service_info(self) -> Any:
+ """
+ 获取服务详情(两个单词方法)
+
+ Returns:
+ Any: 服务详情信息
+ """
+ return self._context.get_service_info(self._service_name)
+
+ def service_status(self) -> dict:
+ """
+ 获取服务状态(两个单词方法)
+
+ Returns:
+ dict: 服务状态信息
+ """
+ return self._context.get_service_status(self._service_name)
+
+ def health_details(self) -> dict:
+ """
+ 获取详细健康信息(两个单词方法)
+
+ Returns:
+ dict: 详细健康检查结果(包含状态、响应时间、时间戳、错误信息等)
+ """
+ try:
+ # 计算实际查询使用的服务名(Agent 模式使用全局名)
+ effective_name = self._service_name
+ if self._context_type == ContextType.AGENT and getattr(self._context, "_service_mapper", None):
+ effective_name = self._context._service_mapper.to_global_name(self._service_name)
+
+ # 使用 orchestrator 的 get_service_status 方法
+ result = self._context._store.orchestrator.get_service_status(
+ effective_name,
+ None # 透明代理:统一在全局命名空间执行健康检查
+ )
+
+ # 保持向后兼容:补齐 effective_name 字段
+ if isinstance(result, dict) and "effective_name" not in result:
+ result = {**result, "effective_name": effective_name, "service_name": self._service_name}
+ return result
+ except Exception as e:
+ logger.error(f"Failed to get health details for {self._service_name}: {e}")
+ return {"service_name": self._service_name, "status": "error", "error": str(e)}
+
+ def find_cache(self) -> "CacheProxy":
+ from .cache_proxy import CacheProxy
+ return CacheProxy(self._context, scope="service", scope_value=self._service_name)
+
+ # === 服务健康检查方法(两个单词) ===
+
+ def check_health(self) -> dict:
+ """
+ 检查服务健康状态(两个单词方法)—返回该服务的健康摘要
+
+ Returns:
+ dict: 健康检查结果(服务级别摘要)
+ """
+ details = self.health_details()
+ # 精简为摘要
+ return {
+ "service_name": details.get("service_name", self._service_name),
+ "status": details.get("status", "unknown"),
+ "healthy": details.get("healthy", False),
+ "response_time": details.get("response_time"),
+ "error_message": details.get("error_message")
+ }
+
+ def is_healthy(self) -> bool:
+ """
+ 检查服务是否健康(两个单词方法)
+
+ Returns:
+ bool: 是否健康
+ """
+ try:
+ # 通过orchestrator检查服务健康状态
+ if self._context_type == ContextType.STORE:
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.is_service_healthy(self._service_name),
+ op_name="service_proxy.is_healthy.store"
+ )
+ else:
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.is_service_healthy(self._service_name, self._agent_id),
+ op_name="service_proxy.is_healthy.agent"
+ )
+ except Exception as e:
+ logger.error(f"Failed to check health for {self._service_name}: {e}")
+ return False
+
+ # === 工具管理方法(两个单词) ===
+
+ def list_tools(self) -> List[ToolInfo]:
+ """
+ 列出服务工具(两个单词方法)
+
+ 直接从 pykv 读取,不使用快照。
+
+ Returns:
+ List[ToolInfo]: 工具列表
+ """
+ return self._context._run_async_via_bridge(
+ self._context.list_tools_async(service_name=self._service_name),
+ op_name="service_proxy.list_tools"
+ )
+
+ def tools_stats(self) -> Dict[str, Any]:
+ """
+ 获取工具统计信息(两个单词方法)
+
+ Returns:
+ Dict[str, Any]: 工具统计信息(仅当前服务)
+ """
+ tools = self.list_tools()
+ return {
+ "tools": [
+ {
+ "name": t.name,
+ "description": t.description,
+ "service_name": t.service_name,
+ "client_id": t.client_id,
+ "inputSchema": t.inputSchema,
+ "has_schema": t.inputSchema is not None
+ }
+ for t in tools
+ ],
+ "metadata": {
+ "total_tools": len(tools),
+ "services_count": 1,
+ "tools_by_service": {self._service_name: len(tools)}
+ }
+ }
+
+ # === 服务管理方法(两个单词) ===
+
+ def update_config(self, config: Dict[str, Any]) -> bool:
+ """
+ 更新服务配置(两个单词方法)
+
+ Args:
+ config: 新的配置
+
+ Returns:
+ bool: 更新是否成功
+ """
+ return self._context.update_service(self._service_name, config)
+
+ def restart_service(self) -> bool:
+ """
+ 重启服务(两个单词方法)
+
+ Returns:
+ bool: 重启是否成功
+ """
+ return self._context.restart_service(self._service_name)
+
+ def delete_service(self) -> bool:
+ """
+ 删除服务(两个单词方法)
+
+ Returns:
+ bool: 删除是否成功
+ """
+ return self._context.delete_service(self._service_name)
+ def patch_config(self, updates: Dict[str, Any]) -> bool:
+ """
+ 增量更新服务配置(两个单词方法)
+
+ Args:
+ updates: 要更新的配置项
+
+ Returns:
+ bool: 是否成功
+ """
+ return self._context.patch_service(self._service_name, updates)
+ def remove_service(self) -> bool:
+ """
+ 移除服务(两个单词方法)
+
+ Returns:
+ bool: 移除是否成功
+ """
+ # 通过orchestrator移除服务(同步封装)
+ try:
+ if self._context_type == ContextType.STORE:
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.remove_service(self._service_name),
+ op_name="service_proxy.remove_service"
+ )
+ else:
+ # Agent 模式需要传递 agent_id
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.remove_service(
+ self._service_name, self._agent_id
+ ),
+ op_name="service_proxy.remove_service"
+ )
+ except Exception as e:
+ logger.error(f"Failed to remove service {self._service_name}: {e}")
+ raise
+
+ # === 服务内容管理方法(两个单词) ===
+
+ def refresh_content(self) -> bool:
+ """
+ 刷新服务内容(两个单词方法)
+
+ Returns:
+ bool: 刷新是否成功
+ """
+ try:
+ if self._context_type == ContextType.STORE:
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.refresh_service_content(self._service_name),
+ op_name="service_proxy.refresh_content"
+ )
+ else:
+ return self._context._run_async_via_bridge(
+ self._context._store.orchestrator.refresh_service_content(self._service_name, self._agent_id),
+ op_name="service_proxy.refresh_content"
+ )
+ except Exception as e:
+ logger.error(f"Failed to refresh content for {self._service_name}: {e}")
+ return False
+
+ def find_tool(self, tool_name: str) -> 'ToolProxy':
+ """
+ 在当前服务范围内查找工具
+
+ 进一步缩小范围到特定服务的工具
+
+ Args:
+ tool_name: 工具名称
+
+ Returns:
+ ToolProxy: 工具代理对象,范围限定为当前服务
+
+ Example:
+ # 先获取服务,再查找服务内的工具
+ weather_service = store.for_store().find_service('weather')
+ weather_tool = weather_service.find_tool('get_current_weather')
+ weather_tool.tool_info() # 获取工具详情
+ weather_tool.call_tool({...}) # 调用工具
+
+ # Agent 模式下的服务工具查找
+ demo_service = store.for_agent('demo1').find_service('service1')
+ demo_tool = demo_service.find_tool('search_tool')
+ demo_tool.usage_stats() # 使用统计
+ """
+ from .tool_proxy import ToolProxy
+ return ToolProxy(self._context, tool_name, scope='service', service_name=self._service_name)
+
+ def call_tool(self, tool_name: str, args: Dict[str, Any] | None = None, return_extracted: bool = False, **kwargs) -> Any:
+ """同步调用当前服务内的工具。"""
+ return self._context._run_async_via_bridge(
+ self.call_tool_async(tool_name, args or {}, return_extracted=return_extracted, **kwargs),
+ op_name="service_proxy.call_tool",
+ )
+
+ async def call_tool_async(self, tool_name: str, args: Dict[str, Any] | None = None, return_extracted: bool = False, **kwargs) -> Any:
+ """异步调用当前服务内的工具。"""
+ return await self._context.call_tool_async(tool_name, args or {}, return_extracted=return_extracted, **kwargs)
+
+ # === Hub 暴露能力 ===
+
+ def hub_http(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/mcp", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前服务对象暴露为 HTTP MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self,
+ transport="http",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_sse(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/sse", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前服务对象暴露为 SSE MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self,
+ transport="sse",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_stdio(self, *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前服务对象暴露为 stdio MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self,
+ transport="stdio",
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ # === 便捷属性方法 ===
+
+ @property
+ def name(self) -> str:
+ """获取服务名称(便捷属性)"""
+ return self._service_name
+
+ @property
+ def tools_count(self) -> int:
+ """获取工具数量(便捷属性)"""
+ return len(self.list_tools())
+
+ @property
+ def is_connected(self) -> bool:
+ """获取连接状态(便捷属性)"""
+ try:
+ service_info = self.service_info()
+ if hasattr(service_info, 'connected'):
+ return service_info.connected
+ elif isinstance(service_info, dict):
+ return service_info.get('connected', False)
+ # 回退:从 orchestrator 的缓存状态判断
+ status = self._context._store.orchestrator.get_service_status(
+ self._service_name,
+ self._agent_id if self._context_type == ContextType.AGENT else None
+ )
+ if isinstance(status, dict):
+ return bool(status.get('healthy', False))
+ return False
+ except Exception:
+ return False
+
+ # === 字符串表示 ===
+
+ def __str__(self) -> str:
+ return f"ServiceProxy(service='{self._service_name}', context='{self._context_type.value}')"
+
+ def __repr__(self) -> str:
+ return self.__str__()
diff --git a/src/mcpstore/core/context/session.py b/src/mcpstore/core/context/session.py
new file mode 100644
index 00000000..bb6d5cb0
--- /dev/null
+++ b/src/mcpstore/core/context/session.py
@@ -0,0 +1,612 @@
+"""
+MCPStore Session Module
+User-friendly Session class that wraps AgentSession with rich functionality
+"""
+
+import inspect
+import logging
+from datetime import datetime
+from typing import Dict, List, Optional, Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from mcpstore.core.agents.session_manager import AgentSession
+ from .base_context import MCPStoreContext
+
+logger = logging.getLogger(__name__)
+
+
+class Session:
+ """
+ User-friendly Session class
+
+ This class provides a clean, object-oriented interface for session management,
+ wrapping the existing AgentSession with user-friendly methods that follow
+ the two-word naming convention.
+
+ Design principles:
+ - Encapsulates existing AgentSession without replacing it
+ - Provides chainable methods for fluent API
+ - Follows two-word naming convention (bind_service, use_tool, etc.)
+ - Reuses existing service discovery and connection logic
+ """
+
+ def __init__(self, context: 'MCPStoreContext', session_id: str, agent_session: 'AgentSession'):
+ """
+ Initialize Session object
+
+ Args:
+ context: MCPStoreContext instance for service operations
+ session_id: User-friendly session identifier
+ agent_session: Underlying AgentSession object
+ """
+ self._context = context
+ self._session_id = session_id
+ self._agent_session = agent_session
+ self._is_active = True
+
+ logger.info(f"[SESSION:{session_id}] Initialized session for agent {agent_session.agent_id}")
+
+ def _run_async(self, coro, op_name: str, timeout: float | None = None):
+ """在统一事件循环中执行协程。"""
+ return self._context._run_async_via_bridge(coro, op_name=op_name, timeout=timeout)
+
+ # === Core Properties ===
+
+ @property
+ def session_id(self) -> str:
+ """Get user-friendly session ID"""
+ return self._session_id
+
+ @property
+ def is_active(self) -> bool:
+ """Check if session is active"""
+ return self._is_active and self._agent_session is not None
+
+ @property
+ def service_count(self) -> int:
+ """Get number of bound services"""
+ return len(self._agent_session.services) if self._agent_session else 0
+
+ @property
+ def tool_count(self) -> int:
+ """Get number of available tools"""
+ return len(self._agent_session.tools) if self._agent_session else 0
+
+ # === Service Management ===
+
+ def bind_service(self, service_name: str) -> 'Session':
+ """
+ Bind service to session
+
+ This method creates a FastMCP Client for the service and caches it
+ in the session for reuse. The Client connection will be maintained
+ until the session is closed.
+
+ Args:
+ service_name: Name of the service to bind
+
+ Returns:
+ Session: Self for method chaining
+
+ Example:
+ session.bind_service("browser")
+ session.bind_service("weather")
+ """
+ if not self.is_active:
+ raise RuntimeError(f"Session {self._session_id} is not active")
+
+ try:
+ # Check if service is already bound
+ if service_name in self._agent_session.services:
+ logger.info(f"[SESSION:{self._session_id}] Service '{service_name}' already bound")
+ return self
+
+ # Use context's sync helper to run async service binding
+ # Bind service quickly; no need for background loop and long timeout
+ self._run_async(
+ self._bind_service_async(service_name),
+ op_name="session.bind_service",
+ timeout=20.0,
+ )
+
+ logger.info(f"[SESSION:{self._session_id}] Successfully bound service '{service_name}'")
+ return self
+
+ except Exception as e:
+ logger.error(f"[SESSION:{self._session_id}] Failed to bind service '{service_name}': {e}")
+ raise
+
+ async def _bind_service_async(self, service_name: str):
+ """
+ Internal async method to bind service
+
+ This method marks the service as bound to the session and eagerly creates
+ a persistent FastMCP client to reduce latency on the first tool call.
+ """
+ # Mark service as bound (placeholder client)
+ self._agent_session.add_service(service_name, None)
+
+ # Eagerly create and cache persistent client to avoid first-call delay
+ try:
+ orchestrator = self._context._store.orchestrator
+ # Use public API exclusively
+ client = await orchestrator.ensure_persistent_client(self._agent_session, service_name)
+ if client:
+ logger.info(f"[SESSION:{self._session_id}] Eager persistent client created for service '{service_name}'")
+ except Exception as e:
+ # Fallback: orchestrator will lazily create on first use
+ logger.warning(f"[SESSION:{self._session_id}] Eager client creation failed for '{service_name}', will create lazily: {e}")
+
+ # Update session activity
+ self._agent_session.update_activity()
+
+ logger.info(f"[SESSION:{self._session_id}] Service '{service_name}' marked as bound")
+ logger.debug(f"[SESSION:{self._session_id}] Service '{service_name}' bound to session")
+
+ # === Tool Execution ===
+
+ def use_tool(self, tool_name: str, arguments: Dict[str, Any] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ Use tool within this session
+
+ This method executes tools using the cached FastMCP Client connections,
+ ensuring that stateful services (like browser) maintain their state
+ across multiple tool calls.
+
+ Args:
+ tool_name: Name of the tool to execute
+ arguments: Tool arguments
+ **kwargs: Additional execution options
+
+ Returns:
+ Any: Tool execution result
+
+ Example:
+ result = session.use_tool("browser_navigate", {"url": "https://baidu.com"})
+ result = session.use_tool("browser_click", {"selector": "#search"})
+ """
+ if not self.is_active:
+ raise RuntimeError(f"Session {self._session_id} is not active")
+
+ # [TIMING] Add precise timing to locate 30s delay
+ import time
+ t_start = time.perf_counter()
+ logger.debug(f"[TIMING] Session.use_tool START: {tool_name}")
+
+ # Use context's sync helper for async execution
+ # [FIX] Remove force_background=True to avoid cross-thread race conditions
+ # Use the same simple waiting mechanism as the regular LangChain adapter
+ # Allow long startup for local stdio services (e.g., first npx run)
+ wrapper_timeout = kwargs.get('timeout', 180.0)
+
+ t_before_run_async = time.perf_counter()
+ logger.debug(f"[TIMING] Before run_async: +{(t_before_run_async - t_start)*1000:.1f}ms")
+
+ result = self._run_async(
+ self.use_tool_async(tool_name, arguments, return_extracted=return_extracted, **kwargs),
+ op_name="session.use_tool",
+ timeout=wrapper_timeout,
+ )
+
+ t_after_run_async = time.perf_counter()
+ logger.debug(f"[TIMING] After run_async: +{(t_after_run_async - t_before_run_async)*1000:.1f}ms, total: +{(t_after_run_async - t_start)*1000:.1f}ms")
+
+ return result
+
+ async def use_tool_async(self, tool_name: str, arguments: Dict[str, Any] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ Use tool within this session (async version)
+
+ This method routes tool execution through the session-aware execution path,
+ which will reuse cached FastMCP Client connections.
+ """
+ arguments = arguments or {}
+
+ logger.info(f"[SESSION:{self._session_id}] Executing tool '{tool_name}' with args: {arguments}")
+
+ # Fast path: avoid pre-fetching available tools to determine service.
+ # Tool name resolution and service binding will be handled downstream by call_tool_async
+ # and orchestrator's session-aware execution path.
+ result = await self._context.call_tool_async(
+ tool_name=tool_name,
+ args=arguments,
+ return_extracted=return_extracted,
+ session_id=self._session_id,
+ **kwargs
+ )
+
+ # Update session activity
+ self._agent_session.update_activity()
+
+ logger.info(f"[SESSION:{self._session_id}] Tool '{tool_name}' executed successfully")
+ return result
+
+ async def _close_client_async(self, client: Any, service_name: str) -> None:
+ """异步关闭底层 FastMCP client。"""
+ close_candidates = [
+ ("close", ()),
+ ("_disconnect", ()),
+ ("__aexit__", (None, None, None)),
+ ]
+ for method_name, args in close_candidates:
+ method = getattr(client, method_name, None)
+ if not method:
+ continue
+ try:
+ result = method(*args)
+ if inspect.isawaitable(result):
+ await result
+ return
+ except Exception as exc:
+ logger.debug(
+ f"[SESSION:{self._session_id}] Error closing client via {method_name} for {service_name}: {exc}"
+ )
+ logger.debug(f"[SESSION:{self._session_id}] No async close method available for client of {service_name}")
+
+ # === Session Information ===
+
+ def session_info(self) -> Dict[str, Any]:
+ """
+ Get comprehensive session information
+
+ Returns:
+ Dict containing session status, statistics, and metadata
+ """
+ if not self._agent_session:
+ return {
+ "session_id": self._session_id,
+ "is_active": False,
+ "error": "Session not initialized"
+ }
+
+ return {
+ "session_id": self._session_id,
+ "agent_id": self._agent_session.agent_id,
+ "is_active": self.is_active,
+ "service_count": self.service_count,
+ "tool_count": self.tool_count,
+ "created_at": self._agent_session.created_at.isoformat(),
+ "last_active": self._agent_session.last_active.isoformat(),
+ "bound_services": list(self._agent_session.services.keys()),
+ "available_tools": list(self._agent_session.tools.keys())
+ }
+
+ def list_services(self) -> List[str]:
+ """
+ List all services bound to this session
+
+ Returns:
+ List of service names
+ """
+ return list(self._agent_session.services.keys()) if self._agent_session else []
+
+ def list_tools(self) -> List[str]:
+ """
+ List all tools available in this session
+
+ Returns:
+ List of tool names
+ """
+ return list(self._agent_session.tools.keys()) if self._agent_session else []
+
+ def connection_status(self) -> Dict[str, Any]:
+ """
+ Get connection status for all bound services
+
+ Returns:
+ Dict with service connection status information
+ """
+ if not self._agent_session:
+ return {}
+
+ status = {}
+ for service_name, client in self._agent_session.services.items():
+ # Check client connection status
+ is_connected = hasattr(client, 'is_connected') and getattr(client, 'is_connected', False)
+ status[service_name] = {
+ "connected": is_connected,
+ "client_type": type(client).__name__
+ }
+
+ return status
+
+ # === Session Lifecycle Management ===
+
+ def extend_session(self, additional_seconds: int = 3600) -> 'Session':
+ """
+ Extend session timeout
+
+ Args:
+ additional_seconds: Additional time to extend session (default: 1 hour)
+
+ Returns:
+ Session: Self for method chaining
+ """
+ if self._agent_session:
+ # Update last_active to effectively extend the session
+ self._agent_session.last_active = datetime.now()
+ logger.info(f"[SESSION:{self._session_id}] Session extended by {additional_seconds} seconds")
+
+ return self
+
+ def clear_cache(self) -> 'Session':
+ """
+ Clear session cache (tools cache, not service connections)
+
+ This clears the tools cache but keeps service connections alive.
+ Use this if you want to refresh tool discovery without reconnecting services.
+
+ Returns:
+ Session: Self for method chaining
+ """
+ if self._agent_session:
+ self._agent_session.tools.clear()
+ logger.info(f"[SESSION:{self._session_id}] Session cache cleared")
+
+ return self
+
+ def restart_session(self) -> 'Session':
+ """
+ Restart session (reconnect all services)
+
+ This closes all current connections and re-establishes them.
+ Use this if you encounter connection issues.
+
+ Returns:
+ Session: Self for method chaining
+ """
+ if not self._agent_session:
+ return self
+
+ try:
+ # Store service names before closing connections
+ service_names = list(self._agent_session.services.keys())
+
+ # Close all existing connections
+ for service_name, client in self._agent_session.services.items():
+ try:
+ self._run_async(
+ self._close_client_async(client, service_name),
+ op_name=f"session.restart.close_client[{service_name}]"
+ )
+ except Exception as e:
+ logger.warning(f"[SESSION:{self._session_id}] Error closing client for {service_name}: {e}")
+
+ # Clear services and tools
+ self._agent_session.services.clear()
+ self._agent_session.tools.clear()
+
+ # Reconnect all services
+ for service_name in service_names:
+ self.bind_service(service_name)
+
+ logger.info(f"[SESSION:{self._session_id}] Session restarted successfully")
+
+ except Exception as e:
+ logger.error(f"[SESSION:{self._session_id}] Error restarting session: {e}")
+ raise
+
+ return self
+
+ def close_session(self) -> None:
+ """
+ Close session and cleanup all resources
+
+ This method closes all FastMCP Client connections and marks the session
+ as inactive. After calling this method, the session cannot be used.
+ """
+ if not self.is_active:
+ logger.warning(f"[SESSION:{self._session_id}] Session already closed")
+ return
+
+ try:
+ # Close all client connections
+ if self._agent_session:
+ for service_name, client in self._agent_session.services.items():
+ try:
+ self._run_async(
+ self._close_client_async(client, service_name),
+ op_name=f"session.close.close_client[{service_name}]"
+ )
+ except Exception as e:
+ logger.warning(f"[SESSION:{self._session_id}] Error closing client for {service_name}: {e}")
+
+ # Clear all caches
+ self._agent_session.services.clear()
+ self._agent_session.tools.clear()
+
+ # Mark session as inactive
+ self._is_active = False
+
+ logger.info(f"[SESSION:{self._session_id}] Session closed successfully")
+
+ except Exception as e:
+ logger.error(f"[SESSION:{self._session_id}] Error closing session: {e}")
+ self._is_active = False # Mark as inactive even if cleanup failed
+ raise
+
+ # === Magic Methods ===
+
+ def __str__(self) -> str:
+ """String representation of session"""
+ return f"Session(id={self._session_id}, services={self.service_count}, tools={self.tool_count}, active={self.is_active})"
+
+ def __repr__(self) -> str:
+ """Detailed representation of session"""
+ return f"Session(session_id='{self._session_id}', agent_id='{self._agent_session.agent_id if self._agent_session else None}', active={self.is_active})"
+
+ def __enter__(self):
+ """Context manager entry (for synchronous use)"""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Context manager exit (for synchronous use)"""
+ self.close_session()
+
+
+class SessionContext:
+ """
+ Asynchronous context manager for session lifecycle management
+
+ This class provides automatic session creation and cleanup using
+ Python's async context manager protocol.
+
+ Example:
+ async with store.for_store().with_session("browser_task") as session:
+ session.bind_service("browser")
+ result = await session.use_tool_async("browser_navigate", {"url": "https://baidu.com"})
+ # Session automatically closed
+ """
+
+ def __init__(self, context: 'MCPStoreContext', session_id: str):
+ """
+ Initialize session context manager
+
+ Args:
+ context: MCPStoreContext instance
+ session_id: User-friendly session identifier
+ """
+ self._context = context
+ self._session_id = session_id
+ self._session: Optional[Session] = None
+ # Track previous active session to support nested contexts
+ self._prev_active_session: Optional[Session] = None
+
+ logger.debug(f"[SESSION_CONTEXT:{session_id}] Context manager initialized")
+
+ def _run_async(self, coro, op_name: str, timeout: float | None = None):
+ return self._context._run_async_via_bridge(coro, op_name=op_name, timeout=timeout)
+
+ async def __aenter__(self) -> Session:
+ """
+ Async context manager entry
+
+ Creates and returns a new session and sets it as the active session
+ for implicit routing within the context scope.
+
+ Returns:
+ Session: New session instance
+ """
+ try:
+ # Create session using context's session management
+ self._session = await self._create_session_async()
+ # Save previous and set current as active for implicit routing
+ self._prev_active_session = getattr(self._context, "_active_session", None)
+ self._context._active_session = self._session
+ logger.info(f"[SESSION_CONTEXT:{self._session_id}] Session created successfully; set as active")
+ return self._session
+
+ except Exception as e:
+ logger.error(f"[SESSION_CONTEXT:{self._session_id}] Failed to create session: {e}")
+ raise
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """
+ Async context manager exit
+
+ Restore previous active session and close the current session.
+
+ Args:
+ exc_type: Exception type (if any)
+ exc_val: Exception value (if any)
+ exc_tb: Exception traceback (if any)
+ """
+ # Restore previous active session if we are the current active
+ try:
+ if getattr(self._context, "_active_session", None) is self._session:
+ self._context._active_session = self._prev_active_session
+ except Exception:
+ pass
+
+ if self._session:
+ try:
+ # Close session asynchronously
+ await self._close_session_async()
+ logger.info(f"[SESSION_CONTEXT:{self._session_id}] Session closed successfully")
+
+ except Exception as e:
+ logger.error(f"[SESSION_CONTEXT:{self._session_id}] Error closing session: {e}")
+ # Don't raise the exception to avoid masking the original exception
+
+ # Clear reference
+ self._session = None
+
+ async def _create_session_async(self) -> Session:
+ """
+ Internal method to create session asynchronously
+
+ Now that SessionManagementMixin is integrated, we can use it to create sessions.
+ """
+ # Use the context's session management to get or create a session (idempotent)
+ return self._context.get_session(self._session_id)
+
+ async def _close_session_async(self):
+ """
+ Internal method to close session asynchronously
+ """
+ if self._session:
+ # Use the session's close method but run it in async context
+ # Since close_session is synchronous, we don't need additional async handling
+ self._session.close_session()
+
+ # === Synchronous Context Manager Protocol ===
+
+ def __enter__(self) -> Session:
+ """
+ Synchronous context manager entry
+
+ Creates and returns a new session using sync helper, and sets it as
+ the active session for implicit routing within the scope.
+
+ Returns:
+ Session: New session instance
+ """
+ try:
+ self._session = self._run_async(
+ self._create_session_async(),
+ op_name=f"session_context.create[{self._session_id}]"
+ )
+ # Save previous and set current as active for implicit routing
+ self._prev_active_session = getattr(self._context, "_active_session", None)
+ self._context._active_session = self._session
+ logger.info(f"[SESSION_CONTEXT:{self._session_id}] Session created successfully (sync); set as active")
+ return self._session
+
+ except Exception as e:
+ logger.error(f"[SESSION_CONTEXT:{self._session_id}] Failed to create session (sync): {e}")
+ raise
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """
+ Synchronous context manager exit
+
+ Restore previous active session and close the current session.
+
+ Args:
+ exc_type: Exception type (if any)
+ exc_val: Exception value (if any)
+ exc_tb: Exception traceback (if any)
+ """
+ try:
+ # Restore previous active session if we are the current active
+ try:
+ if getattr(self._context, "_active_session", None) is self._session:
+ self._context._active_session = self._prev_active_session
+ except Exception:
+ pass
+
+ if self._session:
+ # Close session synchronously to avoid background run_async timeouts
+ try:
+ self._session.close_session()
+ logger.info(f"[SESSION_CONTEXT:{self._session_id}] Session closed successfully (sync)")
+ except Exception as _e:
+ logger.error(f"[SESSION_CONTEXT:{self._session_id}] Error during session close (sync): {_e}")
+ else:
+ logger.warning(f"[SESSION_CONTEXT:{self._session_id}] No session to close (sync)")
+
+ except Exception as e:
+ logger.error(f"[SESSION_CONTEXT:{self._session_id}] Error closing session (sync): {e}")
+ # Don't re-raise exceptions in __exit__ unless critical
+
+ return False # Don't suppress exceptions from the with block
diff --git a/src/mcpstore/core/context/session_management.py b/src/mcpstore/core/context/session_management.py
new file mode 100644
index 00000000..f22ae5fa
--- /dev/null
+++ b/src/mcpstore/core/context/session_management.py
@@ -0,0 +1,816 @@
+"""
+MCPStore Session Management Module
+Session management functionality for MCPStoreContext
+"""
+
+import logging
+from typing import Dict, List, Optional, Any, TYPE_CHECKING
+
+from .types import ContextType
+
+if TYPE_CHECKING:
+ from .session import Session, SessionContext
+
+logger = logging.getLogger(__name__)
+
+
+class SessionManagementMixin:
+ """
+ Session management mixin for MCPStoreContext
+
+ This mixin provides session management functionality that integrates
+ with the existing SessionManager architecture. It follows the principle
+ of maximum reuse and minimum modification.
+
+ Key features:
+ - Create, find, and manage Session objects
+ - Support for both Store and Agent contexts
+ - Automatic session mode (session_auto/session_manual)
+ - Context manager support (with_session)
+ - User-friendly session operations
+ """
+
+ def __init__(self):
+ """
+ Initialize session management state
+
+ This will be called as part of MCPStoreContext.__init__()
+ """
+ # [AUTO] Auto session mode state
+ self._auto_session_enabled = False
+ self._auto_session: Optional['Session'] = None
+ self._auto_session_config: Dict[str, Any] = {}
+
+ # [CACHE] Session cache (avoid creating Session objects repeatedly)
+ self._session_cache: Dict[str, 'Session'] = {}
+
+ # [ACTIVE] Current active session (for implicit session routing)
+ self._active_session: Optional['Session'] = None
+
+
+ logger.debug(f"[SESSION_MANAGEMENT] Initialized for context type: {getattr(self, '_context_type', 'unknown')}")
+
+ # === Core Session Operations ===
+
+ def create_session(self, session_id: str, user_session_id: Optional[str] = None) -> 'Session':
+ """
+ Create a new session (Enhanced version)
+
+ This method creates a new Session object that wraps an AgentSession,
+ with optional cross-context access support through user_session_id.
+
+ Args:
+ session_id: User-friendly session identifier
+ user_session_id: Optional global session ID for cross-context access
+
+ Returns:
+ Session: New session object
+
+ Example:
+ # Basic session
+ session = store.for_store().create_session("browser_task")
+
+ # Cross-context session
+ session = store.for_store().create_session("browser_task", "global_browser_session")
+ # Can be accessed from any context via user_session_id
+ """
+ try:
+ # [AGENT] Get effective agent_id
+ effective_agent_id = self._get_effective_agent_id()
+
+ # [SESSION] Use enhanced SessionManager to create named session
+ if hasattr(self._store.session_manager, 'create_named_session'):
+ # Enhanced SessionManager - use named sessions
+ agent_session = self._store.session_manager.create_named_session(
+ effective_agent_id, session_id, user_session_id
+ )
+ else:
+ # Fallback to original SessionManager
+ agent_session = self._store.session_manager.create_session(effective_agent_id)
+
+ # [CREATE] User-friendly Session object
+ from .session import Session
+ session = Session(self, session_id, agent_session)
+
+ # [CACHE] Session object
+ cache_key = f"{effective_agent_id}:{session_id}"
+ self._session_cache[cache_key] = session
+
+ # [MAPPING] If user_session_id exists, also cache this mapping
+ if user_session_id:
+ self._session_cache[f"user:{user_session_id}"] = session
+
+ logger.info(f"[SESSION_MANAGEMENT] Created session '{session_id}' for agent '{effective_agent_id}'" +
+ (f" with user session ID '{user_session_id}'" if user_session_id else ""))
+ return session
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Failed to create session '{session_id}': {e}")
+ raise
+
+ def find_session(self, session_id: Optional[str] = None, is_user_session_id: bool = False) -> Optional['Session']:
+ """
+ Find an existing session (Enhanced version)
+
+ Args:
+ session_id: Session identifier (optional)
+ If None, returns the auto session if enabled
+ is_user_session_id: If True, treats session_id as a user_session_id for cross-context access
+
+ Returns:
+ Session object if found, None otherwise
+
+ Example:
+ # Local session access
+ session = store.for_store().find_session("browser_task")
+
+ # Cross-context access
+ session = store.for_store().find_session("global_browser_session", is_user_session_id=True)
+
+ # Auto session
+ auto_session = store.for_store().find_session()
+ """
+ try:
+ # [AUTO] If no session_id specified, return auto session
+ if session_id is None:
+ return self._auto_session if self._auto_session_enabled else None
+
+ # [CROSS-CONTEXT] If cross-context access
+ if is_user_session_id:
+ # First check user session cache
+ user_cache_key = f"user:{session_id}"
+ if user_cache_key in self._session_cache:
+ session = self._session_cache[user_cache_key]
+ if session.is_active:
+ return session
+ else:
+ del self._session_cache[user_cache_key]
+
+ # 使用增强的 SessionManager 查找
+ if hasattr(self._store.session_manager, 'get_session_by_user_id'):
+ agent_session = self._store.session_manager.get_session_by_user_id(session_id)
+ if agent_session:
+ from .session import Session
+ session = Session(self, session_id, agent_session)
+ # 缓存用户会话映射
+ self._session_cache[user_cache_key] = session
+ return session
+
+ return None
+
+ # Regular local session lookup
+ effective_agent_id = self._get_effective_agent_id()
+
+ # Check cache
+ cache_key = f"{effective_agent_id}:{session_id}"
+ if cache_key in self._session_cache:
+ session = self._session_cache[cache_key]
+ # 验证底层 AgentSession 是否仍然有效
+ if session.is_active:
+ return session
+ else:
+ # 清理失效的缓存
+ del self._session_cache[cache_key]
+
+ # Use enhanced SessionManager to find named session
+ if hasattr(self._store.session_manager, 'get_named_session'):
+ agent_session = self._store.session_manager.get_named_session(effective_agent_id, session_id)
+ else:
+ # Fallback to original SessionManager
+ agent_session = self._store.session_manager.get_session(effective_agent_id)
+
+ if agent_session:
+ # 创建 Session 对象包装器
+ from .session import Session
+ session = Session(self, session_id, agent_session)
+ # 更新缓存
+ self._session_cache[cache_key] = session
+ return session
+
+ return None
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error finding session '{session_id}': {e}")
+ return None
+
+ def get_session(self, session_id: str) -> 'Session':
+ """
+ Get session (create if not exists)
+
+ Args:
+ session_id: Session identifier
+
+ Returns:
+ Session: Existing or new session object
+
+ Example:
+ session = store.for_store().get_session("browser_task")
+ """
+ session = self.find_session(session_id)
+ if session:
+ return session
+
+ return self.create_session(session_id)
+
+ def list_sessions(self) -> List['Session']:
+ """
+ List all sessions in current context
+
+ Returns:
+ List of Session objects
+
+ Example:
+ sessions = store.for_store().list_sessions()
+ for session in sessions:
+ print(f"Session: {session.session_id}")
+ """
+ try:
+ sessions = []
+ effective_agent_id = self._get_effective_agent_id()
+
+ # Get AgentSession for current context
+ agent_session = self._store.session_manager.get_session(effective_agent_id)
+ if agent_session:
+ # 为这个 AgentSession 创建一个默认的 Session 包装器
+ from .session import Session
+ default_session = Session(self, "default", agent_session)
+ sessions.append(default_session)
+
+ # Include auto session if available
+ if self._auto_session_enabled and self._auto_session:
+ if self._auto_session not in sessions:
+ sessions.append(self._auto_session)
+
+ return sessions
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error listing sessions: {e}")
+ return []
+
+ # === Auto Session Management ===
+
+ def session_auto(self,
+ session_id: str = "auto_session_default",
+ default_timeout: int = 720000,
+ auto_cleanup: bool = False,
+ session_prefix: str = "auto_") -> 'MCPStoreContext':
+ """
+ Enable automatic session mode
+
+ In auto session mode, all tool calls are automatically routed to
+ a persistent session, ensuring state continuity without manual management.
+
+ Args:
+ session_id: Auto session identifier (default: "auto_session_default")
+ default_timeout: Default session timeout in seconds (default: 2 hours)
+ auto_cleanup: Whether to auto-cleanup expired sessions (default: True)
+ session_prefix: Prefix for auto-generated session names (default: "auto_")
+
+ Returns:
+ MCPStoreContext: Self for method chaining
+
+ Example:
+ store.for_store().session_auto()
+ # Now all use_tool calls will be in the same session
+ result1 = store.for_store().use_tool("browser_navigate", {"url": "https://baidu.com"})
+ result2 = store.for_store().use_tool("browser_click", {"selector": "#search"})
+ """
+ try:
+ # Save configuration
+ self._auto_session_config = {
+ "session_id": session_id,
+ "default_timeout": default_timeout,
+ "auto_cleanup": auto_cleanup,
+ "session_prefix": session_prefix
+ }
+
+ # Create or get auto session
+ if not self._auto_session:
+ self._auto_session = self.get_session(session_id)
+
+ # Enable auto session mode
+ self._auto_session_enabled = True
+
+ logger.info(f"[SESSION_MANAGEMENT] Auto session mode enabled with session '{session_id}'")
+ return self
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Failed to enable auto session mode: {e}")
+ raise
+
+ def session_manual(self) -> 'MCPStoreContext':
+ """
+ Switch to manual session mode
+
+ Disables automatic session routing. Tool calls will use traditional
+ mode unless explicitly called with a session.
+
+ Returns:
+ MCPStoreContext: Self for method chaining
+
+ Example:
+ store.for_store().session_manual()
+ # Tool calls now use traditional mode (new connection each time)
+ """
+ self._auto_session_enabled = False
+ logger.info("[SESSION_MANAGEMENT] Switched to manual session mode")
+ return self
+
+ def is_session_auto(self) -> bool:
+ """
+ Check if automatic session mode is enabled
+
+ Returns:
+ bool: True if auto session mode is active
+ """
+ return self._auto_session_enabled
+
+ def current_session(self) -> Optional['Session']:
+ """
+ Get current auto session (if auto mode is enabled)
+
+ Returns:
+ Session: Current auto session, or None if not in auto mode
+
+ Example:
+ auto_session = store.for_store().current_session()
+ if auto_session:
+ auto_session.extend_session(3600)
+ """
+ return self._auto_session if self._auto_session_enabled else None
+
+ # === Context Manager Support ===
+
+ def with_session(self, session_id: str) -> 'SessionContext':
+ """
+ Create session context manager
+
+ This provides automatic session lifecycle management using Python's
+ context manager protocol.
+
+ Args:
+ session_id: Session identifier
+
+ Returns:
+ SessionContext: Async context manager
+
+ Example:
+ with store.for_store().with_session("browser_task") as session:
+ session.bind_service("browser")
+ result = session.use_tool("browser_navigate", {"url": "https://baidu.com"})
+ # Session automatically closed
+ """
+ from .session import SessionContext
+ return SessionContext(self, session_id)
+
+ async def with_session_async(self, session_id: str) -> 'SessionContext':
+ """
+ Create async session context manager
+
+ Args:
+ session_id: Session identifier
+
+ Returns:
+ SessionContext: Async context manager
+
+ Example:
+ async with store.for_store().with_session_async("browser_task") as session:
+ await session.bind_service_async("browser")
+ result = await session.use_tool_async("browser_navigate", {"url": "https://baidu.com"})
+ """
+ return self.with_session(session_id)
+
+ # === Session Management Operations ===
+
+ def close_all_sessions(self) -> 'MCPStoreContext':
+ """
+ Close all sessions in current context
+
+ Returns:
+ MCPStoreContext: Self for method chaining
+
+ Example:
+ store.for_store().close_all_sessions()
+ """
+ try:
+ # Close all cached Session objects
+ for session in list(self._session_cache.values()):
+ try:
+ session.close_session()
+ except Exception as e:
+ logger.warning(f"[SESSION_MANAGEMENT] Error closing session {session.session_id}: {e}")
+
+ # Clear cache
+ self._session_cache.clear()
+
+ # Close auto session
+ if self._auto_session:
+ try:
+ self._auto_session.close_session()
+ except Exception as e:
+ logger.warning(f"[SESSION_MANAGEMENT] Error closing auto session: {e}")
+ self._auto_session = None
+
+ # Disable auto session mode
+ self._auto_session_enabled = False
+
+ logger.info("[SESSION_MANAGEMENT] All sessions closed")
+ return self
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error closing all sessions: {e}")
+ return self
+
+ def cleanup_sessions(self) -> 'MCPStoreContext':
+ """
+ Cleanup expired sessions
+
+ Returns:
+ MCPStoreContext: Self for method chaining
+ """
+ try:
+ # Use existing SessionManager to cleanup expired sessions
+ self._store.session_manager.cleanup_expired_sessions()
+
+ # Clean up invalid cache
+ invalid_keys = []
+ for key, session in self._session_cache.items():
+ if not session.is_active:
+ invalid_keys.append(key)
+
+ for key in invalid_keys:
+ del self._session_cache[key]
+
+ logger.info(f"[SESSION_MANAGEMENT] Cleaned up {len(invalid_keys)} expired session cache entries")
+ return self
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error during session cleanup: {e}")
+ return self
+
+ def restart_sessions(self) -> 'MCPStoreContext':
+ """
+ Restart all sessions (reconnect all services)
+
+ Returns:
+ MCPStoreContext: Self for method chaining
+ """
+ try:
+ # Restart all cached sessions
+ for session in self._session_cache.values():
+ try:
+ session.restart_session()
+ except Exception as e:
+ logger.warning(f"[SESSION_MANAGEMENT] Error restarting session {session.session_id}: {e}")
+
+ # Restart auto session
+ if self._auto_session:
+ try:
+ self._auto_session.restart_session()
+ except Exception as e:
+ logger.warning(f"[SESSION_MANAGEMENT] Error restarting auto session: {e}")
+
+ logger.info("[SESSION_MANAGEMENT] All sessions restarted")
+ return self
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error restarting sessions: {e}")
+ return self
+
+ # === Enhanced Session Management Methods ===
+
+ def find_user_session(self, user_session_id: str) -> Optional['Session']:
+ """
+ Find session by user-defined session ID (cross-context access)
+
+ This is a convenience method that calls find_session with is_user_session_id=True.
+
+ Args:
+ user_session_id: User-defined session identifier
+
+ Returns:
+ Session object if found, None otherwise
+
+ Example:
+ # Access session from any context
+ session = store.for_store().find_user_session("global_browser_session")
+ session = store.for_agent("team_2").find_user_session("global_browser_session")
+ # Both return the same session!
+ """
+ return self.find_session(user_session_id, is_user_session_id=True)
+
+ def create_shared_session(self, session_id: str, shared_id: str) -> 'Session':
+ """
+ Create a session that can be accessed across contexts
+
+ This is a convenience method that creates a session with a user_session_id.
+
+ Args:
+ session_id: Local session identifier
+ shared_id: Global shared identifier for cross-context access
+
+ Returns:
+ Session: Created session object
+
+ Example:
+ # Create shared session in store context
+ session = store.for_store().create_shared_session("browser_work", "global_browser")
+
+ # Access from agent context
+ same_session = store.for_agent("team_1").find_user_session("global_browser")
+ """
+ return self.create_session(session_id, user_session_id=shared_id)
+
+ def list_agent_sessions(self) -> List['Session']:
+ """
+ List all sessions for current agent (Enhanced version)
+
+ Returns:
+ List of Session objects for the current agent
+
+ Example:
+ sessions = store.for_agent("team_1").list_agent_sessions()
+ for session in sessions:
+ print(f"Session: {session.session_id}")
+ """
+ try:
+ sessions = []
+ effective_agent_id = self._get_effective_agent_id()
+
+ # Use enhanced SessionManager
+ if hasattr(self._store.session_manager, 'list_sessions_for_agent'):
+ agent_sessions_dict = self._store.session_manager.list_sessions_for_agent(effective_agent_id)
+
+ for session_name, agent_session in agent_sessions_dict.items():
+ from .session import Session
+ session = Session(self, session_name, agent_session)
+ sessions.append(session)
+ else:
+ # Fallback to original logic
+ agent_session = self._store.session_manager.get_session(effective_agent_id)
+ if agent_session:
+ from .session import Session
+ session = Session(self, "default", agent_session)
+ sessions.append(session)
+
+ # Include auto session if available
+ if self._auto_session_enabled and self._auto_session:
+ if self._auto_session not in sessions:
+ sessions.append(self._auto_session)
+
+ return sessions
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error listing agent sessions: {e}")
+ return []
+
+ def get_session_statistics(self) -> Dict[str, Any]:
+ """
+ Get session statistics for current context
+
+ Returns:
+ Dictionary with session statistics
+
+ Example:
+ stats = store.for_store().get_session_statistics()
+ print(f"Total sessions: {stats['total_sessions']}")
+ """
+ try:
+ if hasattr(self._store.session_manager, 'get_session_statistics'):
+ # Enhanced SessionManager statistics
+ global_stats = self._store.session_manager.get_session_statistics()
+
+ # Add context-specific information
+ effective_agent_id = self._get_effective_agent_id()
+ agent_sessions = self.list_agent_sessions()
+
+ context_stats = {
+ "context_type": "store" if self._context_type.name == "STORE" else "agent",
+ "agent_id": effective_agent_id,
+ "context_sessions": len(agent_sessions),
+ "auto_session_enabled": self._auto_session_enabled,
+ "cached_session_objects": len(self._session_cache)
+ }
+
+ return {**global_stats, "context_info": context_stats}
+ else:
+ # Basic statistics for original SessionManager
+ agent_sessions = self.list_agent_sessions()
+ return {
+ "context_sessions": len(agent_sessions),
+ "auto_session_enabled": self._auto_session_enabled,
+ "cached_session_objects": len(self._session_cache)
+ }
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error getting session statistics: {e}")
+ return {"error": str(e)}
+
+ def register_session_globally(self, session_id: str, global_id: str) -> bool:
+ """
+ Register an existing session for global access
+
+ Args:
+ session_id: Local session identifier
+ global_id: Global identifier for cross-context access
+
+ Returns:
+ bool: True if registration successful, False otherwise
+
+ Example:
+ # Create local session
+ session = store.for_store().create_session("browser_work")
+
+ # Register for global access
+ success = store.for_store().register_session_globally("browser_work", "shared_browser")
+
+ # Now accessible globally
+ same_session = store.for_agent("team_1").find_user_session("shared_browser")
+ """
+ try:
+ effective_agent_id = self._get_effective_agent_id()
+
+ if hasattr(self._store.session_manager, 'register_user_session'):
+ success = self._store.session_manager.register_user_session(
+ global_id, effective_agent_id, session_id
+ )
+
+ if success:
+ # Update local cache
+ session = self.find_session(session_id)
+ if session:
+ self._session_cache[f"user:{global_id}"] = session
+
+ return success
+ else:
+ logger.warning("[SESSION_MANAGEMENT] Global session registration not supported by current SessionManager")
+ return False
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error registering session globally: {e}")
+ return False
+
+ # === LangChain Integration ===
+
+ def for_langchain_with_session(self, session_id: str, create_if_not_exists: bool = True) -> 'SessionAwareLangChainAdapter':
+ """
+ Create a session-aware LangChain adapter
+
+ This method creates LangChain tools that are bound to a specific session,
+ ensuring state persistence across multiple tool calls in LangChain workflows.
+
+ Args:
+ session_id: Session identifier
+ create_if_not_exists: Whether to create session if it doesn't exist (default: True)
+
+ Returns:
+ SessionAwareLangChainAdapter: Session-bound LangChain adapter
+
+ Example:
+ # Create session-bound LangChain tools
+ session_adapter = store.for_store().for_langchain_with_session("browser_session")
+ tools = session_adapter.list_tools()
+
+ # Use with LangChain agent - browser state will persist!
+ agent = create_react_agent(llm, tools)
+ result = agent.invoke({"messages": [HumanMessage("打开百度,然后搜索天气")]})
+ """
+ try:
+ # Get or create session
+ session = self.find_session(session_id)
+ if not session and create_if_not_exists:
+ session = self.create_session(session_id)
+ elif not session:
+ raise ValueError(f"Session '{session_id}' not found and create_if_not_exists=False")
+
+ # Create session-aware adapter
+ # Note: 这是一个桥接方法,存在 core → adapters 的向上依赖
+ # 但为了 API 便利性保留,使用延迟导入减少影响
+ from mcpstore.adapters.langchain_adapter import SessionAwareLangChainAdapter
+ adapter = SessionAwareLangChainAdapter(self, session)
+
+ logger.info(f"[SESSION_MANAGEMENT] Created session-aware LangChain adapter for session '{session_id}'")
+ return adapter
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error creating session-aware LangChain adapter: {e}")
+ raise
+
+ def for_langchain_with_auto_session(self) -> 'SessionAwareLangChainAdapter':
+ """
+ Create a LangChain adapter using the current auto session
+
+ This is a convenience method for using the auto session with LangChain.
+ Auto session mode must be enabled first.
+
+ Returns:
+ SessionAwareLangChainAdapter: Auto session-bound LangChain adapter
+
+ Example:
+ # Enable auto session mode
+ store.for_store().session_auto()
+
+ # Create LangChain tools bound to auto session
+ session_adapter = store.for_store().for_langchain_with_auto_session()
+ tools = session_adapter.list_tools()
+
+ # All tool calls will automatically use the same session
+ agent = create_react_agent(llm, tools)
+ """
+ if not self._auto_session_enabled or not self._auto_session:
+ raise RuntimeError("Auto session mode is not enabled. Call session_auto() first.")
+
+ # Note: 桥接方法,延迟导入减少向上依赖影响
+ from mcpstore.adapters.langchain_adapter import SessionAwareLangChainAdapter
+ adapter = SessionAwareLangChainAdapter(self, self._auto_session)
+
+ logger.info("[SESSION_MANAGEMENT] Created LangChain adapter for auto session")
+ return adapter
+
+ def for_langchain_with_shared_session(self, shared_id: str) -> 'SessionAwareLangChainAdapter':
+ """
+ Create a LangChain adapter using a shared session (cross-context access)
+
+ Args:
+ shared_id: Shared session identifier
+
+ Returns:
+ SessionAwareLangChainAdapter: Shared session-bound LangChain adapter
+
+ Example:
+ # Access shared session from any context
+ session_adapter = store.for_store().for_langchain_with_shared_session("global_browser")
+ session_adapter = store.for_agent("team_1").for_langchain_with_shared_session("global_browser")
+ # Both return tools bound to the same session!
+ """
+ try:
+ session = self.find_user_session(shared_id)
+ if not session:
+ raise ValueError(f"Shared session '{shared_id}' not found")
+
+ # Note: 桥接方法,延迟导入减少向上依赖影响
+ from mcpstore.adapters.langchain_adapter import SessionAwareLangChainAdapter
+ adapter = SessionAwareLangChainAdapter(self, session)
+
+ logger.info(f"[SESSION_MANAGEMENT] Created LangChain adapter for shared session '{shared_id}'")
+ return adapter
+
+ except Exception as e:
+ logger.error(f"[SESSION_MANAGEMENT] Error creating LangChain adapter for shared session: {e}")
+ raise
+
+ # === Internal Helper Methods ===
+
+ def _get_effective_agent_id(self) -> str:
+ """
+ Get effective agent ID for current context
+
+ Returns:
+ str: Agent ID to use for session operations
+ """
+ if self._context_type == ContextType.STORE:
+ # Store 上下文使用 global_agent_store_id
+ return self._store.client_manager.global_agent_store_id
+ else:
+ # Agent 上下文使用实际的 agent_id
+ return self._agent_id
+
+ def _use_tool_with_session(self, tool_name: str, args: Dict[str, Any] = None, **kwargs) -> Any:
+ """
+ Internal method to execute tool with automatic session
+
+ This method is called when auto session mode is enabled.
+ It routes tool execution to the auto session.
+
+ Args:
+ tool_name: Tool name
+ args: Tool arguments
+ **kwargs: Additional arguments
+
+ Returns:
+ Tool execution result
+ """
+ if not self._auto_session:
+ raise RuntimeError("Auto session not initialized")
+
+ logger.debug(f"[SESSION_MANAGEMENT] Routing tool '{tool_name}' to auto session")
+ # Avoid passing duplicate session_id when routing to session API
+ kwargs.pop('session_id', None)
+ return self._auto_session.use_tool(tool_name, args, **kwargs) # return_extracted - propagated by callers
+
+ async def _use_tool_with_session_async(self, tool_name: str, args: Dict[str, Any] = None, **kwargs) -> Any:
+ """
+ Internal async method to execute tool with automatic session
+
+ This method routes tool execution through the session-aware path by creating
+ a ToolExecutionRequest with session_id and calling the store's process_tool_request.
+ """
+ if not self._auto_session:
+ raise RuntimeError("Auto session not initialized")
+
+ logger.debug(f"[SESSION_MANAGEMENT] Routing tool '{tool_name}' to auto session (async)")
+
+ # 使用 Session 的 use_tool_async 方法,它会直接使用缓存的 FastMCP Client
+ # Avoid duplicate session_id when delegating to Session API
+ kwargs.pop('session_id', None)
+ return await self._auto_session.use_tool_async(tool_name, args, **kwargs)
diff --git a/src/mcpstore/core/context/store_proxy.py b/src/mcpstore/core/context/store_proxy.py
new file mode 100644
index 00000000..dab90475
--- /dev/null
+++ b/src/mcpstore/core/context/store_proxy.py
@@ -0,0 +1,442 @@
+"""
+StoreProxy - objectified store-view proxy.
+Lightweight, stateless handle that delegates to the underlying context.
+All data is retrieved on demand from registry/cache to ensure freshness.
+"""
+
+from typing import Any, Dict, List, TYPE_CHECKING
+
+from mcpstore.core.models.tool import ToolInfo
+
+if TYPE_CHECKING:
+ from .base_context import MCPStoreContext
+ from .service_proxy import ServiceProxy
+ from .agent_proxy import AgentProxy
+ from .tool_proxy import ToolProxy
+
+
+class StoreProxy:
+ def __init__(self, context: "MCPStoreContext"):
+ self._context = context
+
+ # ---- Identity & info ----
+ def get_id(self) -> str:
+ return getattr(self._context._store.client_manager, "global_agent_store_id", "global_agent_store")
+
+ def get_info(self) -> Dict[str, Any]:
+ # Reuse setup_config snapshot as store info
+ return self._context.setup_config()
+
+ def get_stats(self) -> Dict[str, Any]:
+ services = self.list_services()
+ tools = self.list_tools()
+ return {
+ "services": len(services),
+ "tools": len(tools),
+ }
+
+ # ---- Lists & queries ----
+ def list_services(self, *args, **kwargs) -> List[Dict[str, Any]]:
+ # 直接返回 ServiceInfo 模型列表,调用方如需 JSON 可自行 model_dump()
+ return self._context.list_services(*args, **kwargs)
+
+ def list_tools(self, *args, **kwargs):
+ """
+ 列出工具列表
+
+ 直接返回 ToolInfo 对象列表,不转换为字典。
+
+ Returns:
+ List[ToolInfo]: 工具列表
+ """
+ return self._context.list_tools(*args, **kwargs)
+
+ def find_service(self, name: str) -> "ServiceProxy":
+ from .service_proxy import ServiceProxy
+ return ServiceProxy(self._context, name)
+
+ def list_agents(self) -> List[Dict[str, Any]]:
+ # 同步方法,使用异步桥在统一事件循环中执行
+ return self._context._run_async_via_bridge(
+ self.list_agents_async(),
+ op_name="store_proxy.list_agents"
+ )
+
+ async def list_agents_async(self) -> List[Dict[str, Any]]:
+ registry = self._context._store.registry
+ global_agent_id = self._context._store.client_manager.global_agent_store_id
+ agent_ids = set(await registry.get_all_agent_ids_async() or [])
+ agent_ids.add(global_agent_id)
+
+ # 读取 Agent 元数据(可选)
+ agents_entities = await registry._cache_layer_manager.get_all_entities_async("agents") or {}
+
+ result: List[Dict[str, Any]] = []
+ for agent_id in sorted(agent_ids):
+ # 从 pykv 获取 Agent 客户端
+ client_ids = await registry.get_agent_clients_async(agent_id)
+ # Agent 服务列表(全局名)
+ global_service_names = await registry.get_agent_services_async(agent_id) or []
+ tool_count = 0
+ healthy = 0
+ unhealthy = 0
+ for gname in global_service_names:
+ tools = await registry.get_tools_for_service_async(global_agent_id, gname) or []
+ tool_count += len(tools)
+ state = await registry._service_state_service.get_service_state_async(
+ global_agent_id,
+ gname
+ )
+ state_value = getattr(state, "value", str(state))
+ if state_value in ("healthy", "warning"):
+ healthy += 1
+ else:
+ unhealthy += 1
+
+ agent_meta = agents_entities.get(agent_id) if isinstance(agents_entities, dict) else {}
+ result.append({
+ "agent_id": agent_id,
+ "client_ids": client_ids,
+ "service_count": len(global_service_names),
+ "tool_count": tool_count,
+ "healthy_services": healthy,
+ "unhealthy_services": unhealthy,
+ "is_active": bool(global_service_names and healthy > 0),
+ "last_activity": agent_meta.get("last_active") if isinstance(agent_meta, dict) else None,
+ })
+ return result
+
+ def find_cache(self) -> "CacheProxy":
+ from .cache_proxy import CacheProxy
+ return CacheProxy(self._context, scope="global", scope_value=None)
+
+ def find_agent(self, agent_id: str) -> "AgentProxy":
+ """
+ Find agent proxy with unified caching.
+
+ Uses the centralized AgentProxy caching system to ensure that the same
+ agent_id always returns the same AgentProxy instance across all access
+ methods in the MCPStore.
+
+ Args:
+ agent_id: Unique identifier for the agent
+
+ Returns:
+ AgentProxy: Cached or newly created AgentProxy instance
+ """
+ # Use unified AgentProxy caching system from the store
+ return self._context._store._get_or_create_agent_proxy(self._context, agent_id)
+
+ # ---- Health & runtime ----
+ def check_services(self) -> Dict[str, Any]:
+ return self._context.check_services()
+
+ def call_tool(self, tool_name: str, args: Dict[str, Any]):
+ """
+ 调用工具(同步版本),直接返回 FastMCP CallToolResult。
+
+ 需要结构化/文本化视图的调用方,应该自行从结果的 content / structured_content / data 中提取。
+ """
+ return self._context.call_tool(tool_name, args)
+
+ # ---- Mutations ----
+ def add_service(self, config: Dict[str, Any]) -> bool:
+ return bool(self._context.add_service(config))
+
+ def update_service(self, name: str, patch: Dict[str, Any]) -> bool:
+ return bool(self._context.update_service(name, patch))
+
+ def delete_service(self, name: str) -> bool:
+ return bool(self._context.delete_service(name))
+
+ # Async counterparts (explicit wrappers)
+ async def add_service_async(self, *args, **kwargs):
+ return await self._context.add_service_async(*args, **kwargs)
+
+ async def call_tool_async(self, tool_name: str, args: Dict[str, Any]):
+ return await self._context.call_tool_async(tool_name, args)
+
+ async def show_config_async(self) -> Dict[str, Any]:
+ return await self._context.show_config_async()
+
+ async def delete_config_async(self, client_id_or_service_name: str) -> Dict[str, Any]:
+ return await self._context.delete_config_async(client_id_or_service_name)
+
+ async def reset_config_async(self) -> bool:
+ return await self._context.reset_config_async()
+
+ async def get_tool_records_async(self, limit: int = 50) -> Dict[str, Any]:
+ return await self._context.get_tool_records_async(limit)
+
+ # ---- Service info/status & extended ops ----
+ def get_service_info(self, name: str) -> Dict[str, Any]:
+ info = self._context.get_service_info(name)
+ try:
+ if hasattr(info, "model_dump"):
+ return info.model_dump()
+ if hasattr(info, "dict"):
+ return info.dict()
+ if isinstance(info, dict):
+ return info
+ return {"result": str(info)}
+ except Exception:
+ return {"result": str(info)}
+
+ def get_service_status(self, name: str) -> Dict[str, Any]:
+ status = self._context.get_service_status(name)
+ try:
+ if hasattr(status, "model_dump"):
+ return status.model_dump()
+ if hasattr(status, "dict"):
+ return status.dict()
+ if isinstance(status, dict):
+ return status
+ return {"result": str(status)}
+ except Exception:
+ return {"result": str(status)}
+
+
+ def patch_service(self, name: str, updates: Dict[str, Any]) -> bool:
+ return bool(self._context.patch_service(name, updates))
+
+ async def patch_service_async(self, name: str, updates: Dict[str, Any]) -> bool:
+ return await self._context.patch_service_async(name, updates)
+
+ def restart_service(self, name: str) -> bool:
+ return bool(self._context.restart_service(name))
+
+ async def restart_service_async(self, name: str) -> bool:
+ return await self._context.restart_service_async(name)
+
+ def use_tool(self, tool_name: str, args: Any = None, **kwargs) -> Any:
+ # Delegate to context; leave result as-is to match use_tool semantics
+ return self._context.use_tool(tool_name, args, **kwargs)
+
+ async def check_services_async(self) -> Dict[str, Any]:
+ return await self._context.check_services_async()
+
+ async def get_service_info_async(self, name: str) -> Dict[str, Any]:
+ info = await self._context.get_service_info_async(name)
+ try:
+ if hasattr(info, "model_dump"):
+ return info.model_dump()
+ if hasattr(info, "dict"):
+ return info.dict()
+ if isinstance(info, dict):
+ return info
+ return {"result": str(info)}
+ except Exception:
+ return {"result": str(info)}
+
+ async def get_service_status_async(self, name: str) -> Dict[str, Any]:
+ status = await self._context.get_service_status_async(name)
+ try:
+ if hasattr(status, "model_dump"):
+ return status.model_dump()
+ if hasattr(status, "dict"):
+ return status.dict()
+ if isinstance(status, dict):
+ return status
+ return {"result": str(status)}
+ except Exception:
+ return {"result": str(status)}
+
+ # ---- Resources & Prompts ----
+ def list_resources(self, service_name: str = None) -> Dict[str, Any]:
+ return self._context.list_resources(service_name)
+
+ def list_resource_templates(self, service_name: str = None) -> Dict[str, Any]:
+ return self._context.list_resource_templates(service_name)
+
+ def read_resource(self, uri: str, service_name: str = None) -> Dict[str, Any]:
+ return self._context.read_resource(uri, service_name)
+
+ def list_prompts(self, service_name: str = None) -> Dict[str, Any]:
+ return self._context.list_prompts(service_name)
+
+ def get_prompt(self, name: str, arguments: Dict[str, Any] = None, service_name: str = None) -> Dict[str, Any]:
+ return self._context.get_prompt(name, arguments, service_name)
+
+ def list_changed_tools(self, service_name: str = None, force_refresh: bool = False) -> Dict[str, Any]:
+ return self._context.list_changed_tools(service_name, force_refresh)
+
+ # ---- Config management ----
+ def reset_config(self) -> bool:
+ return bool(self._context.reset_config())
+
+ def show_config(self) -> Dict[str, Any]:
+ return self._context.show_config()
+
+ def switch_cache(self, cache_config: Any) -> bool:
+ """Runtime cache backend switching (synchronous version)."""
+ return bool(self._context.switch_cache(cache_config))
+
+ async def switch_cache_async(self, cache_config: Any) -> bool:
+ """Runtime cache backend switching (asynchronous version)."""
+ return await self._context.switch_cache_async(cache_config)
+
+ # ---- Statistics ----
+ def get_agents_summary(self) -> Any:
+ summary = getattr(self._context, "get_agents_summary", None)
+ if callable(summary):
+ res = summary()
+ try:
+ if hasattr(res, "model_dump"):
+ return res.model_dump()
+ if hasattr(res, "dict"):
+ return res.dict()
+ except Exception:
+ pass
+ return res
+ return {}
+
+ # ---- Adapters (delegations) ----
+ def for_langchain(self, response_format: str = "text"):
+ return self._context.for_langchain(response_format=response_format)
+
+ def for_llamaindex(self):
+ return self._context.for_llamaindex()
+
+ def for_crewai(self):
+ return self._context.for_crewai()
+
+ def for_langgraph(self, response_format: str = "text"):
+ return self._context.for_langgraph(response_format=response_format)
+
+ def for_autogen(self):
+ return self._context.for_autogen()
+
+ def for_semantic_kernel(self):
+ return self._context.for_semantic_kernel()
+
+ def for_openai(self):
+ return self._context.for_openai()
+
+ # ---- Sessions (delegations) ----
+ def with_session(self, session_id: str):
+ return self._context.with_session(session_id)
+
+ async def with_session_async(self, session_id: str):
+ return await self._context.with_session_async(session_id)
+
+ def create_session(self, session_id: str, user_session_id: str = None):
+ return self._context.create_session(session_id, user_session_id)
+
+ def find_session(self, session_id: str = None, is_user_session_id: bool = False):
+ return self._context.find_session(session_id, is_user_session_id)
+
+ def get_session(self, session_id: str):
+ return self._context.get_session(session_id)
+
+ def list_sessions(self):
+ return self._context.list_sessions()
+
+ def close_all_sessions(self):
+ return self._context.close_all_sessions()
+
+ def cleanup_sessions(self):
+ return self._context.cleanup_sessions()
+
+ def restart_sessions(self):
+ return self._context.restart_sessions()
+
+ def find_user_session(self, user_session_id: str):
+ return self._context.find_user_session(user_session_id)
+
+ def create_shared_session(self, session_id: str, shared_id: str):
+ return self._context.create_shared_session(session_id, shared_id)
+
+ # ---- Lifecycle / waiters ----
+ def wait_service(self, client_id_or_service_name: str, status = 'healthy', timeout: float = 10.0, raise_on_timeout: bool = False) -> bool:
+ return self._context.wait_service(client_id_or_service_name, status, timeout, raise_on_timeout)
+
+ async def wait_service_async(self, client_id_or_service_name: str, status = 'healthy', timeout: float = 10.0, raise_on_timeout: bool = False) -> bool:
+ return await self._context.wait_service_async(client_id_or_service_name, status, timeout, raise_on_timeout)
+
+ def init_service(self, client_id_or_service_name: str = None, *, client_id: str = None, service_name: str = None):
+ return self._context.init_service(client_id_or_service_name, client_id=client_id, service_name=service_name)
+
+ async def init_service_async(self, client_id_or_service_name: str = None, *, client_id: str = None, service_name: str = None):
+ return await self._context.init_service_async(client_id_or_service_name, client_id=client_id, service_name=service_name)
+
+ # ---- Advanced features ----
+ def import_api(self, api_url: str, api_name: str = None):
+ return self._context.import_api(api_url, api_name)
+
+ async def import_api_async(self, api_url: str, api_name: str = None):
+ return await self._context.import_api_async(api_url, api_name)
+
+ def reset_mcp_json_file(self) -> bool:
+ return self._context.reset_mcp_json_file()
+
+ async def reset_mcp_json_file_async(self, scope: str = "all") -> bool:
+ return await self._context.reset_mcp_json_file_async(scope)
+
+ # ---- Hub MCP helpers ----
+ def hub_http(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/mcp", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """
+ 将当前 Store 暴露为 HTTP MCP 端点。
+
+ Args:
+ port: 监听端口
+ host: 监听地址
+ path: HTTP 路径
+ background: 是否在后台线程运行(默认阻塞当前调用)
+ show_banner: 是否显示 FastMCP 启动横幅
+ **fastmcp_kwargs: 透传给 FastMCP 的参数(如 auth)
+ Returns:
+ HubMCPServer: Hub 服务器实例,可用于 stop()/restart()
+ """
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._context,
+ transport="http",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_sse(self, port: int = 8000, host: str = "0.0.0.0", path: str = "/sse", *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前 Store 暴露为 SSE MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._context,
+ transport="sse",
+ port=port,
+ host=host,
+ path=path,
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ def hub_stdio(self, *, block: bool = False, show_banner: bool = False, **fastmcp_kwargs):
+ """将当前 Store 暴露为 stdio MCP 端点。"""
+ from mcpstore.core.hub.server import HubMCPServer
+
+ hub = HubMCPServer(
+ exposed_object=self._context,
+ transport="stdio",
+ **fastmcp_kwargs,
+ )
+ hub.start(block=block, show_banner=show_banner)
+ return hub
+
+ # ---- Tool lookup ----
+ def find_tool(self, tool_name: str):
+ from .tool_proxy import ToolProxy
+ return ToolProxy(self._context, tool_name, scope='context')
+
+ # ---- Escape hatch ----
+ def get_context(self):
+ return self._context
+
+ # ---- Compatibility: delegate unknown attrs to context ----
+ def __getattr__(self, name: str):
+ # Fallback delegation to preserve existing callsites expecting context methods
+ return getattr(self._context, name)
diff --git a/src/mcpstore/core/context/tool_operations.py b/src/mcpstore/core/context/tool_operations.py
new file mode 100644
index 00000000..bd3e9eed
--- /dev/null
+++ b/src/mcpstore/core/context/tool_operations.py
@@ -0,0 +1,1643 @@
+"""
+MCPStore Tool Operations Module
+Implementation of tool-related operations
+
+架构原则:Functional Core, Imperative Shell
+- 同步版本 (list_tools): 通过 Async Orchestrated Bridge 运行在统一事件循环
+- 异步版本 (list_tools_async): 在现有事件循环中执行
+- 纯逻辑核心 (ToolLogicCore): 只做计算,不做 IO
+- pykv 是唯一真相数据源,不使用内存快照
+"""
+
+import logging
+from typing import Dict, List, Optional, Any, Union, Literal
+
+from mcp import types as mcp_types
+
+from mcpstore.core.logic.tool_logic import ToolLogicCore
+from mcpstore.core.models.tool import ToolInfo
+from .types import ContextType
+
+logger = logging.getLogger(__name__)
+
+
+class ToolOperationsMixin:
+ """
+ 工具操作混入类
+
+ 遵循 Functional Core, Imperative Shell 架构:
+ - 同步方法统一通过 Async Orchestrated Bridge 在后台事件循环运行
+ - 异步方法在现有事件循环中执行
+ - 所有数据从 pykv 读取,不使用内存快照
+ """
+
+ # ==================== 工具可用性检查 ====================
+
+ def _is_tool_available(
+ self,
+ service_global_name: str,
+ tool_name: str,
+ *,
+ tool_original_name: Optional[str] = None,
+ service_original_name: Optional[str] = None,
+ ) -> bool:
+ """
+ 检查工具是否可用(同步外壳)
+
+ 通过 Async Orchestrated Bridge 在稳定事件循环中执行异步逻辑。
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_name: 工具名称
+
+ Returns:
+ True 如果工具可用,否则 False
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具状态不存在
+ """
+ return self._run_async_via_bridge(
+ self._is_tool_available_async(
+ service_global_name,
+ tool_name,
+ tool_original_name=tool_original_name,
+ service_original_name=service_original_name,
+ ),
+ op_name="tool_operations.is_tool_available"
+ )
+
+ async def _is_tool_available_async(
+ self,
+ service_global_name: str,
+ tool_name: str,
+ *,
+ tool_original_name: Optional[str] = None,
+ service_original_name: Optional[str] = None,
+ ) -> bool:
+ """
+ 检查工具是否可用(异步外壳)
+
+ 从 pykv 状态层读取数据,使用纯逻辑核心进行计算。
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_name: 工具名称
+
+ Returns:
+ True 如果工具可用,否则 False
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具状态不存在
+ """
+ # 从 pykv 状态层读取服务状态
+ state_manager = self._store.registry._cache_state_manager
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ # 使用纯逻辑核心进行计算
+ # 将 ServiceStatus 对象转换为字典
+ status_dict = service_status.to_dict() if service_status else None
+
+ is_available = ToolLogicCore.check_tool_availability(
+ service_global_name,
+ tool_name,
+ status_dict,
+ tool_original_name_override=tool_original_name,
+ service_original_name=service_original_name,
+ )
+
+ logger.debug(
+ f"Tool availability check: service={service_global_name}, "
+ f"tool={tool_name}, available={is_available}"
+ )
+
+ return is_available
+
+ def _extract_original_tool_name(self, tool_name: str, service_name: str) -> str:
+ """
+ 提取工具的原始名称(去除服务前缀)
+
+ 委托给纯逻辑核心。
+ """
+ return ToolLogicCore.extract_original_tool_name(tool_name, service_name)
+
+ # ==================== list_tools 双路外壳 ====================
+
+ def list_tools(
+ self,
+ service_name: Optional[str] = None,
+ *,
+ filter: Literal["available", "all"] = "available"
+ ) -> List[ToolInfo]:
+ """
+ 列出工具(同步外壳)
+
+ 通过 Async Orchestrated Bridge 在稳定事件循环中执行异步操作。
+ 遵循 Functional Core, Imperative Shell 架构。
+
+ Args:
+ service_name: 服务名称(可选,None表示所有服务)
+ filter: 筛选范围
+ - "available": 当前可用工具(默认)
+ - "all": 原始完整工具
+
+ Returns:
+ 工具列表
+ """
+ return self._run_async_via_bridge(
+ self.list_tools_async(service_name, filter=filter),
+ op_name="tool_operations.list_tools"
+ )
+
+ async def list_tools_async(
+ self,
+ service_name: Optional[str] = None,
+ *,
+ filter: Literal["available", "all"] = "available"
+ ) -> List[ToolInfo]:
+ """
+ 列出工具(异步外壳)
+
+ 直接从 pykv 读取数据,不使用内存快照。
+ 遵循 Functional Core, Imperative Shell 架构。
+
+ 数据读取路径:
+ 1. 关系层:获取 Agent 的服务列表
+ 2. 关系层:获取每个服务的工具列表
+ 3. 实体层:批量获取工具实体
+ 4. 状态层:获取服务状态(用于可用性过滤)
+
+ Args:
+ service_name: 服务名称(可选,None表示所有服务)
+ filter: 筛选范围
+ - "available": 当前可用工具(默认)
+ - "all": 原始完整工具
+
+ Returns:
+ 工具列表
+ """
+ logger.info(f"[LIST_TOOLS] start filter={filter} context_type={self._context_type.name}")
+
+ # 确定 agent_id
+ if self._context_type == ContextType.AGENT:
+ agent_id = self._agent_id
+ else:
+ agent_id = self._store.orchestrator.client_manager.global_agent_store_id
+
+ # ==================== 从 pykv 读取数据 ====================
+
+ # 获取管理器
+ relation_manager = self._store.registry._relation_manager
+ tool_entity_manager = self._store.registry._cache_tool_manager
+ state_manager = self._store.registry._cache_state_manager
+
+ # Step 1: 从关系层获取 Agent 的服务列表
+ agent_services = await relation_manager.get_agent_services(agent_id)
+ logger.debug(f"[LIST_TOOLS] agent_services count={len(agent_services)}")
+
+ if not agent_services:
+ logger.info(f"[LIST_TOOLS] no services for agent_id={agent_id}")
+ return []
+
+ # Step 2: 从关系层获取每个服务的工具列表
+ all_tool_global_names: List[str] = []
+ service_tool_map: Dict[str, List[str]] = {} # service_global_name -> [tool_global_names]
+
+ for svc in agent_services:
+ service_global_name = svc.get("service_global_name")
+ if not service_global_name:
+ continue
+
+ tool_relations = await relation_manager.get_service_tools(service_global_name)
+ tool_names = [
+ tr.get("tool_global_name")
+ for tr in tool_relations
+ if tr.get("tool_global_name")
+ ]
+
+ service_tool_map[service_global_name] = tool_names
+ all_tool_global_names.extend(tool_names)
+
+ logger.debug(f"[LIST_TOOLS] total tools to fetch={len(all_tool_global_names)}")
+
+ if not all_tool_global_names:
+ logger.info(f"[LIST_TOOLS] no tools for agent_id={agent_id}")
+ return []
+
+ # Step 3: 从实体层批量获取工具实体
+ tool_entities = await tool_entity_manager.get_many_tools(all_tool_global_names)
+
+ # 构建 client_id 映射
+ client_id_map: Dict[str, str] = {}
+ for svc in agent_services:
+ service_global_name = svc.get("service_global_name")
+ client_id = svc.get("client_id")
+ if service_global_name and client_id:
+ client_id_map[service_global_name] = client_id
+
+ # ==================== 使用纯逻辑核心构建工具列表 ====================
+
+ # 将实体对象转换为字典
+ entity_dicts = [
+ e.to_dict() if e else None
+ for e in tool_entities
+ ]
+
+ # 构建工具列表
+ all_tools: List[ToolInfo] = []
+ for i, entity_dict in enumerate(entity_dicts):
+ if entity_dict is None:
+ continue
+
+ service_global_name = entity_dict.get("service_global_name", "")
+ service_original_name = entity_dict.get("service_original_name", "")
+ client_id = client_id_map.get(service_global_name)
+
+ tool_info = ToolInfo(
+ name=entity_dict.get("tool_global_name", ""),
+ tool_original_name=entity_dict.get("tool_original_name", ""),
+ description=entity_dict.get("description", ""),
+ service_name=service_original_name,
+ service_original_name=service_original_name,
+ service_global_name=service_global_name,
+ client_id=client_id,
+ inputSchema=entity_dict.get("input_schema", {})
+ )
+ all_tools.append(tool_info)
+
+ # 按服务名筛选
+ if service_name:
+ all_tools = [t for t in all_tools if t.service_name == service_name]
+
+ # 如果 filter="all",直接返回
+ if filter == "all":
+ logger.info(f"[LIST_TOOLS] filter=all count={len(all_tools)}")
+ return all_tools
+
+ # ==================== filter="available",从状态层过滤 ====================
+
+ # Step 4: 从状态层获取服务状态
+ service_status_map: Dict[str, Dict[str, Any]] = {}
+ for svc in agent_services:
+ service_global_name = svc.get("service_global_name")
+ if not service_global_name:
+ continue
+
+ status = await state_manager.get_service_status(service_global_name)
+ if status:
+ service_status_map[service_global_name] = status.to_dict()
+
+ # 使用纯逻辑核心过滤工具
+ filtered_tools: List[ToolInfo] = []
+ for tool in all_tools:
+ tool_service_global_name = getattr(tool, "service_global_name", None)
+ if not tool_service_global_name:
+ raise RuntimeError(f"[LIST_TOOLS] Tool missing service_global_name: tool={tool.name}")
+
+ # 获取服务状态
+ status_dict = service_status_map.get(tool_service_global_name)
+
+ # 使用纯逻辑核心检查可用性
+ try:
+ is_available = ToolLogicCore.check_tool_availability(
+ tool_service_global_name,
+ tool.name,
+ status_dict,
+ tool_original_name_override=getattr(tool, "tool_original_name", None),
+ service_original_name=getattr(tool, "service_original_name", None),
+ )
+ if is_available:
+ filtered_tools.append(tool)
+ except RuntimeError as e:
+ # 状态/工具不存在,抛出错误
+ raise
+
+ logger.info(
+ f"[LIST_TOOLS] filter=available agent_id={agent_id} "
+ f"total={len(all_tools)} available={len(filtered_tools)}"
+ )
+ return filtered_tools
+
+ def get_tools_with_stats(self) -> Dict[str, Any]:
+ """
+ Get tool list and statistics (synchronous version)
+
+ Returns:
+ Dict: Tool list and statistics
+ """
+ return self._run_async_via_bridge(
+ self.get_tools_with_stats_async(),
+ op_name="tool_operations.get_tools_with_stats"
+ )
+
+ async def get_tools_with_stats_async(self) -> Dict[str, Any]:
+ """
+ Get tool list and statistics (asynchronous version)
+
+ Returns:
+ Dict: Tool list and statistics
+ """
+ try:
+ tools = await self.list_tools_async()
+
+ # 修复:返回完整的工具信息,包括Vue前端需要的所有字段
+ tools_data = [
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "service_name": tool.service_name,
+ "client_id": tool.client_id,
+ "inputSchema": tool.inputSchema, # 完整的参数schema
+ "has_schema": tool.inputSchema is not None # 保持向后兼容
+ }
+ for tool in tools
+ ]
+
+ # 按服务分组统计
+ tools_by_service = {}
+ for tool in tools:
+ service_name = tool.service_name
+ if service_name not in tools_by_service:
+ tools_by_service[service_name] = 0
+ tools_by_service[service_name] += 1
+
+ # 修复:返回API期望的格式
+ return {
+ "tools": tools_data,
+ "metadata": {
+ "total_tools": len(tools),
+ "services_count": len(tools_by_service),
+ "tools_by_service": tools_by_service
+ }
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to get tools with stats: {e}")
+ # 修复:错误情况下也返回API期望的格式
+ return {
+ "tools": [],
+ "metadata": {
+ "total_tools": 0,
+ "services_count": 0,
+ "tools_by_service": {},
+ "error": str(e)
+ }
+ }
+
+ def get_system_stats(self) -> Dict[str, Any]:
+ """
+ 获取系统统计信息(同步版本)
+
+ Returns:
+ Dict: 系统统计信息
+ """
+ return self._run_async_via_bridge(
+ self.get_system_stats_async(),
+ op_name="tool_operations.get_system_stats"
+ )
+
+ async def get_system_stats_async(self) -> Dict[str, Any]:
+ """
+ 获取系统统计信息(异步版本)
+
+ Returns:
+ Dict: 系统统计信息
+ """
+ try:
+ services = await self.list_services_async()
+ tools = await self.list_tools_async()
+
+ # 计算统计信息
+ stats = {
+ "total_services": len(services),
+ "total_tools": len(tools),
+ "healthy_services": len([s for s in services if getattr(s, "status", None) == "healthy"]),
+ "context_type": self._context_type.value,
+ "agent_id": self._agent_id,
+ "services_by_status": {},
+ "tools_by_service": {}
+ }
+
+ # 按状态分组服务
+ for service in services:
+ status = getattr(service, "status", "unknown")
+ if status not in stats["services_by_status"]:
+ stats["services_by_status"][status] = 0
+ stats["services_by_status"][status] += 1
+
+ # 按服务分组工具
+ for tool in tools:
+ service_name = tool.service_name
+ if service_name not in stats["tools_by_service"]:
+ stats["tools_by_service"][service_name] = 0
+ stats["tools_by_service"][service_name] += 1
+
+ return stats
+
+ except Exception as e:
+ logger.error(f"Failed to get system stats: {e}")
+ return {
+ "total_services": 0,
+ "total_tools": 0,
+ "healthy_services": 0,
+ "context_type": self._context_type.value,
+ "agent_id": self._agent_id,
+ "services_by_status": {},
+ "tools_by_service": {},
+ "error": str(e)
+ }
+
+ def batch_add_services(self, services: List[Union[str, Dict[str, Any]]]) -> Dict[str, Any]:
+ """
+ 批量添加服务(同步版本)
+
+ Args:
+ services: 服务列表
+
+ Returns:
+ Dict: 批量添加结果
+ """
+ return self._run_async_via_bridge(
+ self.batch_add_services_async(services),
+ op_name="tool_operations.batch_add_services"
+ )
+
+ async def batch_add_services_async(self, services: List[Union[str, Dict[str, Any]]]) -> Dict[str, Any]:
+ """
+ 批量添加服务(异步版本)
+
+ Args:
+ services: 服务列表
+
+ Returns:
+ Dict: 批量添加结果
+ """
+ try:
+ if not services:
+ return {
+ "success": False,
+ "message": "No services provided",
+ "added_services": [],
+ "failed_services": [],
+ "total_added": 0
+ }
+
+ # 使用现有的 add_service_async 方法
+ result = await self.add_service_async(services)
+
+ # 获取添加后的服务列表
+ current_services = await self.list_services_async()
+ service_names = [getattr(s, "name", "unknown") for s in current_services]
+
+ return {
+ "success": True,
+ "message": f"Batch operation completed",
+ "added_services": service_names,
+ "failed_services": [],
+ "total_added": len(service_names)
+ }
+
+ except Exception as e:
+ logger.error(f"Batch add services failed: {e}")
+ return {
+ "success": False,
+ "message": str(e),
+ "added_services": [],
+ "failed_services": services if isinstance(services, list) else [str(services)],
+ "total_added": 0
+ }
+
+ def call_tool(self, tool_name: str, args: Union[Dict[str, Any], str] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ 调用工具(同步版本),支持 store/agent 上下文
+
+ 用户友好的工具调用接口,支持以下工具名称格式:
+ - 直接工具名: "get_weather"
+ - 服务前缀(单下划线): "weather_get_weather"
+ 注意:不再支持双下划线格式 "service__tool";如使用将抛出错误并提示迁移方案
+
+ Args:
+ tool_name: 工具名称(支持多种格式)
+ args: 工具参数(字典或JSON字符串)
+ **kwargs: 额外参数(timeout, progress_handler等)
+
+ Returns:
+ Any: 工具执行结果
+ - 单个内容块:直接返回字符串/数据
+ - 多个内容块:返回列表
+ """
+ return self._run_async_via_bridge(
+ self.call_tool_async(tool_name, args, return_extracted=return_extracted, **kwargs),
+ op_name="tool_operations.call_tool"
+ )
+
+ def use_tool(self, tool_name: str, args: Union[Dict[str, Any], str] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ 使用工具(同步版本)- 向后兼容别名
+
+ 注意:此方法是 call_tool 的别名,保持向后兼容性。
+ 推荐使用 call_tool 方法,与 FastMCP 命名保持一致。
+ """
+ return self.call_tool(tool_name, args, return_extracted=return_extracted, **kwargs)
+
+ async def call_tool_async(self, tool_name: str, args: Dict[str, Any] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ 调用工具(异步版本),支持 store/agent 上下文
+
+ Args:
+ tool_name: 工具名称(支持多种格式)
+ args: 工具参数
+ **kwargs: 额外参数(timeout, progress_handler等)
+
+ Returns:
+ Any: 工具执行结果(FastMCP 标准格式)
+ """
+ args = args or {}
+
+ # Implicit session routing: when in with_session scope and no explicit session_id, prioritize current active session
+ if getattr(self, '_active_session', None) is not None and 'session_id' not in kwargs:
+ try:
+ logger.debug(f"[IMPLICIT_SESSION] Routing tool '{tool_name}' to active session '{self._active_session.session_id}'")
+ except Exception:
+ logger.debug(f"[IMPLICIT_SESSION] Routing tool '{tool_name}' to active session")
+ # Avoid duplicate session_id when delegating to Session API
+ kwargs.pop('session_id', None)
+ return await self._active_session.use_tool_async(tool_name, args, return_extracted=return_extracted, **kwargs)
+
+ # Auto session routing: only route when auto session is enabled and no explicit session_id is provided
+ if getattr(self, '_auto_session_enabled', False) and 'session_id' not in kwargs:
+ logger.debug(f"[AUTO_SESSION] Routing tool '{tool_name}' to auto session (no explicit session_id)")
+ return await self._use_tool_with_session_async(tool_name, args, return_extracted=return_extracted, **kwargs)
+ elif getattr(self, '_auto_session_enabled', False) and 'session_id' in kwargs:
+ logger.debug("[AUTO_SESSION] Enabled but explicit session_id provided; skip auto routing")
+
+ # Implicit session routing: if with_session activated a session and no explicit session_id provided, route to that session
+ active_session = getattr(self, '_active_session', None)
+ if active_session is not None and getattr(active_session, 'is_active', False) and 'session_id' not in kwargs:
+ logger.debug(f"[ACTIVE_SESSION] Routing tool '{tool_name}' to active session '{active_session.session_id}'")
+ kwargs.pop('session_id', None)
+ return await active_session.use_tool_async(tool_name, args, return_extracted=return_extracted, **kwargs)
+
+ # 获取可用工具列表用于智能解析
+ available_tools = []
+ try:
+ if self._context_type == ContextType.STORE:
+ tools = await self._store.list_tools()
+ else:
+ tools = await self._store.list_tools(self._agent_id, agent_mode=True)
+
+ # 构建工具信息,包含显示名称和原始名称
+ for tool in tools:
+ # Agent模式:需要转换服务名称为本地名称
+ if self._context_type == ContextType.AGENT and self._agent_id:
+ # 透明代理:将全局服务名转换为本地服务名(从缓存源读取)
+ local_service_name = await self._get_local_service_name_from_global_async(tool.service_global_name)
+ if local_service_name:
+ # 构建本地工具名称
+ local_tool_name = self._convert_tool_name_to_local(
+ tool.name,
+ tool.service_global_name,
+ local_service_name,
+ getattr(tool, "tool_original_name", None),
+ )
+ display_name = local_tool_name
+ service_name = local_service_name
+ else:
+ # 如果无法映射,使用原始名称
+ display_name = tool.name
+ service_name = tool.service_original_name
+ else:
+ display_name = tool.name
+ service_name = tool.service_original_name
+
+ original_name = getattr(tool, "tool_original_name", None) or self._extract_original_tool_name(display_name, service_name)
+
+ available_tools.append({
+ "name": display_name, # 显示名称(Agent模式下使用本地名称)
+ "original_name": original_name, # 原始名称
+ "service_name": service_name, # 服务名称(Agent模式下使用本地名称)
+ "global_tool_name": tool.name, # 保存全局工具名称用于实际调用
+ "global_service_name": tool.service_global_name # 保存全局服务名称
+ })
+
+ logger.debug(f"Available tools for resolution: {len(available_tools)}")
+ except Exception as e:
+ logger.warning(f"Failed to get available tools for resolution: {e}")
+
+ # [NEW] Use new intelligent user-friendly resolver
+ from mcpstore.core.registry.tool_resolver import ToolNameResolver
+
+ # 检测是否为多服务场景(从已获取的工具列表推导,避免同步→异步桥导致的30s超时)
+ derived_services = sorted({
+ t.get("service_name") for t in available_tools
+ if isinstance(t, dict) and t.get("service_name")
+ })
+
+ is_multi_server = len(derived_services) > 1
+
+ resolver = ToolNameResolver(
+ available_services=derived_services,
+ is_multi_server=is_multi_server
+ )
+
+ try:
+ # One-stop resolution: user input -> FastMCP standard format
+ fastmcp_tool_name, resolution = resolver.resolve_and_format_for_fastmcp(tool_name, available_tools)
+
+ logger.info(f"[SMART_RESOLVE] input='{tool_name}' fastmcp='{fastmcp_tool_name}' service='{resolution.service_name}' method='{resolution.resolution_method}'")
+
+ except ValueError as e:
+ # LLM-readable error: tool name resolution failed, return structured error for model understanding
+ return self._build_call_tool_error_result(
+ f"[LLM Hint] Tool name resolution failed: {str(e)}. Please check the tool name or add service prefix, e.g. service_tool."
+ )
+
+ # 工具可用性拦截:Store 和 Agent 模式都检查工具是否可用
+ # 获取服务的全局名称
+ if self._context_type == ContextType.AGENT and self._agent_id:
+ # Agent 模式:需要将本地服务名映射到全局服务名
+ service_global_name = await self._map_agent_tool_to_global_service(
+ resolution.service_name, fastmcp_tool_name
+ )
+ else:
+ # Store 模式:服务名就是全局名称
+ service_global_name = resolution.service_name
+
+ # 检查工具是否可用
+ is_available = await self._is_tool_available_async(
+ service_global_name,
+ fastmcp_tool_name,
+ tool_original_name=fastmcp_tool_name,
+ service_original_name=resolution.service_name,
+ )
+
+ if not is_available:
+ # 工具不可用,抛出异常
+ from mcpstore.core.exceptions import ToolNotAvailableError
+
+ original_tool_name = self._extract_original_tool_name(fastmcp_tool_name, resolution.service_name)
+ agent_id = self._agent_id if self._context_type == ContextType.AGENT else "global_agent_store"
+
+ logger.warning(
+ f"[TOOL_INTERCEPT] Tool not available: agent_id={agent_id}, "
+ f"service_global_name={service_global_name}, tool={original_tool_name}"
+ )
+
+ raise ToolNotAvailableError(
+ tool_name=original_tool_name,
+ service_name=resolution.service_name,
+ agent_id=agent_id
+ )
+
+ logger.debug(
+ f"[TOOL_INTERCEPT] Tool availability check passed: "
+ f"service_global_name={service_global_name}, tool={fastmcp_tool_name}"
+ )
+
+ # 构造标准化的工具执行请求
+ from mcpstore.core.models.tool import ToolExecutionRequest
+
+ if self._context_type == ContextType.STORE:
+ logger.info(f"[STORE] call tool='{tool_name}' fastmcp='{fastmcp_tool_name}' service='{resolution.service_name}'")
+ request = ToolExecutionRequest(
+ tool_name=fastmcp_tool_name, # [FASTMCP] Use FastMCP standard format
+ service_name=resolution.service_name,
+ args=args,
+ **kwargs
+ )
+ else:
+ # Agent mode: Transparent proxy - map local service name to global service name
+ global_service_name = await self._map_agent_tool_to_global_service(resolution.service_name, fastmcp_tool_name)
+
+ logger.info(f"[AGENT:{self._agent_id}] call tool='{tool_name}' fastmcp='{fastmcp_tool_name}' service_local='{resolution.service_name}' service_global='{global_service_name}'")
+ request = ToolExecutionRequest(
+ tool_name=fastmcp_tool_name, # [FASTMCP] Use FastMCP standard format
+ service_name=global_service_name, # Use global service name
+ args=args,
+ # Agent 场景使用真实 agent_id,确保关系层查询服务映射正确
+ agent_id=self._agent_id,
+ **kwargs
+ )
+
+ response = await self._store.process_tool_request(request)
+
+ # Convert execution errors to LLM-readable format to avoid code interruption
+ if hasattr(response, 'success') and not response.success:
+ stored_result = getattr(response, 'result', None)
+ if stored_result is not None:
+ return stored_result
+ msg = getattr(response, 'error', 'Tool execution failed')
+ return self._build_call_tool_error_result(
+ f"[LLM Hint] Tool invocation failed: {msg}"
+ )
+
+ if return_extracted:
+ try:
+ from mcpstore.core.registry.tool_resolver import FastMCPToolExecutor
+ executor = FastMCPToolExecutor()
+ return executor.extract_result_data(response.result)
+ except Exception:
+ # 兜底:无法提取则直接返回原结果
+ return getattr(response, 'result', None)
+ else:
+ # 默认返回 FastMCP 的 CallToolResult(或等价对象)
+ return getattr(response, 'result', None)
+
+ async def use_tool_async(self, tool_name: str, args: Dict[str, Any] = None, **kwargs) -> Any:
+ """
+ 使用工具(异步版本)- 向后兼容别名
+
+ 注意:此方法是 call_tool_async 的别名,保持向后兼容性。
+ 推荐使用 call_tool_async 方法,与 FastMCP 命名保持一致。
+ """
+ return await self.call_tool_async(tool_name, args, **kwargs)
+
+ # === 新增:Agent 工具调用透明代理方法 ===
+
+ async def _map_agent_tool_to_global_service(self, local_service_name: str, tool_name: str) -> str:
+ """
+ 将 Agent 的本地服务名映射到全局服务名(异步版本)
+
+ 使用新架构:通过 RelationshipManager 从 pykv 缓存源读取映射关系
+ 遵循 pykv 数据唯一源原则和完全异步调用链
+
+ Args:
+ local_service_name: Agent 中的本地服务名
+ tool_name: 工具名称
+
+ Returns:
+ str: 全局服务名
+ """
+ try:
+ # 1. 检查是否为 Agent 服务
+ if self._agent_id and local_service_name:
+ # 使用异步接口从缓存源获取全局名称
+ global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ self._agent_id, local_service_name
+ )
+ if global_name:
+ logger.debug(f"[TOOL_PROXY] map local='{local_service_name}' -> global='{global_name}'")
+ return global_name
+
+ # 2. 如果映射失败,检查是否已经是全局名称
+ from .agent_service_mapper import AgentServiceMapper
+ if AgentServiceMapper.is_any_agent_service(local_service_name):
+ logger.debug(f"[TOOL_PROXY] already_global name='{local_service_name}'")
+ return local_service_name
+
+ # 3. 如果都不是,可能是 Store 原生服务,直接返回
+ logger.debug(f"[TOOL_PROXY] store_native name='{local_service_name}'")
+ return local_service_name
+
+ except Exception as e:
+ logger.error(f"[TOOL_PROXY] map_error error={e}")
+ # 出错时返回原始名称
+ return local_service_name
+
+ async def _get_agent_tools_view(self) -> List[ToolInfo]:
+ """
+ 获取 Agent 的工具视图(本地名称)
+
+ 透明代理(方案A):基于映射从 global_agent_store 的缓存派生工具列表,
+ 不依赖 Agent 命名空间的 sessions/tool_cache。
+ """
+ try:
+ agent_tools: List[ToolInfo] = []
+ agent_id = self._agent_id
+ global_agent_id = self._store.client_manager.global_agent_store_id
+
+ # 1) 通过映射获取该 Agent 的全局服务名集合
+ global_service_names = self._store.registry.get_agent_services(agent_id)
+ if not global_service_names:
+ logger.info(f"[AGENT_TOOLS] view agent='{agent_id}' count=0 (no mapped services)")
+ return agent_tools
+
+ # 2) 遍历映射的全局服务,读取其工具并转换为本地名称
+ for global_service_name in global_service_names:
+ mapping = self._store.registry.get_agent_service_from_global_name(global_service_name)
+ if not mapping:
+ continue
+ mapped_agent, local_service_name = mapping
+ if mapped_agent != agent_id:
+ continue
+
+ try:
+ # 获取该服务的工具名列表(从全局命名空间)
+ service_tool_names = self._store.registry.get_tools_for_service(
+ global_agent_id,
+ global_service_name
+ )
+
+ for tool_name in service_tool_names:
+ try:
+ tool_info = self._store.registry.get_tool_info(global_agent_id, tool_name)
+ if not tool_info:
+ logger.warning(f"[AGENT_TOOLS] tool_info_missing name='{tool_name}'")
+ continue
+
+ # 转换工具名为本地名称
+ local_tool_name = self._convert_tool_name_to_local(
+ tool_name,
+ global_service_name,
+ local_service_name,
+ tool_info.get("tool_original_name")
+ )
+
+ # 创建本地工具视图(client_id 使用全局命名空间)
+ local_tool = ToolInfo(
+ name=local_tool_name,
+ tool_original_name=tool_info.get('tool_original_name', ''),
+ description=tool_info.get('description', ''),
+ service_name=local_service_name,
+ service_original_name=local_service_name,
+ service_global_name=global_service_name,
+ inputSchema=tool_info.get('inputSchema', {}),
+ client_id=tool_info.get('client_id', '')
+ )
+ agent_tools.append(local_tool)
+ logger.debug(f"[AGENT_TOOLS] add name='{local_tool_name}' service='{local_service_name}'")
+ except Exception as e:
+ logger.error(f"[AGENT_TOOLS] tool_error name='{tool_name}' error={e}")
+ continue
+ except Exception as e:
+ logger.error(f"[AGENT_TOOLS] service_tools_error service='{local_service_name}' error={e}")
+ continue
+
+ logger.info(f"[AGENT_TOOLS] view agent='{agent_id}' count={len(agent_tools)}")
+ return agent_tools
+
+ except Exception as e:
+ logger.error(f"[AGENT_TOOLS] view_error error={e}")
+ return []
+
+ def _convert_tool_name_to_local(self, global_tool_name: str, global_service_name: str, local_service_name: str, tool_original_name: Optional[str] = None) -> str:
+ """
+ 将全局工具名转换为本地工具名
+
+ Args:
+ global_tool_name: 全局工具名
+ global_service_name: 全局服务名
+ local_service_name: 本地服务名
+
+ Returns:
+ str: 本地工具名
+ """
+ try:
+ if tool_original_name:
+ return f"{local_service_name}_{tool_original_name}"
+
+ if global_tool_name.startswith(f"{global_service_name}_"):
+ tool_suffix = global_tool_name[len(global_service_name) + 1:]
+ return f"{local_service_name}_{tool_suffix}"
+ return global_tool_name
+
+ except Exception as e:
+ logger.error(f"[TOOL_NAME_CONVERT] Tool name conversion failed: {e}")
+ return global_tool_name
+
+ async def _get_local_service_name_from_global_async(self, global_service_name: str) -> Optional[str]:
+ """
+ 从全局服务名获取本地服务名(异步版本)
+
+ 使用新架构:通过 RelationshipManager 从 pykv 缓存源读取映射关系
+ 遵循 pykv 数据唯一源原则,不依赖内存字典
+
+ Args:
+ global_service_name: 全局服务名
+
+ Returns:
+ Optional[str]: 本地服务名,如果不是当前 Agent 的服务则返回 None
+ """
+ try:
+ if not self._agent_id:
+ return None
+
+ # 使用新架构的异步接口从缓存源读取
+ result = await self._store.registry.get_agent_service_from_global_name_async(
+ global_service_name
+ )
+
+ if result:
+ agent_id, local_name = result
+ # 只返回当前 Agent 的服务映射
+ if agent_id == self._agent_id:
+ return local_name
+
+ return None
+
+ except Exception as e:
+ logger.error(f"[SERVICE_NAME_CONVERT] Service name conversion failed: {e}")
+ return None
+
+ # ==================== 工具集管理方法 ====================
+
+ def _resolve_service(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]]
+ ) -> Union[str, List[str]]:
+ """
+ 解析服务参数为服务名称
+
+ Args:
+ service: 服务标识,支持三种类型:
+ - str: 服务名称
+ - ServiceProxy: 服务代理对象
+ - "_all_services": 保留字符串,表示所有服务
+
+ Returns:
+ 服务名称字符串或服务名称列表(当 service="_all_services" 时)
+
+ Raises:
+ ValueError: 如果参数类型不支持
+ CrossAgentOperationError: 如果尝试跨 Agent 操作
+
+ Validates: Requirements 6.9 (跨 Agent 操作防护)
+ """
+ from mcpstore.core.exceptions import CrossAgentOperationError
+
+ # 处理 "_all_services" 保留字符串
+ if service == "_all_services":
+ # 获取所有服务名称
+ services = self.list_services()
+ return [getattr(s, "name", str(s)) for s in services]
+
+ # 处理 ServiceProxy 对象
+ if hasattr(service, "name"):
+ # 验证 ServiceProxy 归属(跨 Agent 操作防护)
+ if hasattr(service, "is_agent_scoped") and service.is_agent_scoped:
+ # 检查 ServiceProxy 是否属于当前 Agent
+ service_agent_id = getattr(service, "agent_id", None)
+ current_agent_id = self._agent_id
+
+ if service_agent_id and current_agent_id and service_agent_id != current_agent_id:
+ raise CrossAgentOperationError(
+ current_agent_id=current_agent_id,
+ service_agent_id=service_agent_id,
+ service_name=service.name,
+ operation="工具集管理"
+ )
+
+ logger.debug(f"[TOOL_OPERATIONS] Verified ServiceProxy ownership for '{service.name}'")
+
+ return service.name
+
+ # 处理字符串
+ if isinstance(service, str):
+ return service
+
+ raise ValueError(f"Unsupported service parameter type: {type(service)}")
+
+ async def _verify_data_source_ownership(
+ self,
+ agent_id: str,
+ service_name: str
+ ) -> None:
+ """
+ 验证数据源归属
+
+ 检查工具集状态和服务映射是否存在
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Raises:
+ DataSourceNotFoundError: 数据源不存在
+ ServiceMappingError: 服务映射不存在
+
+ Validates: Requirements 6.6, 6.10 (数据源归属验证)
+ """
+ from mcpstore.core.exceptions import DataSourceNotFoundError
+
+ # 获取服务的全局名称(异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ agent_id, service_name
+ )
+
+ if not service_global_name:
+ raise DataSourceNotFoundError(
+ agent_id=agent_id,
+ service_name=service_name,
+ data_type="service_mapping"
+ )
+
+ # 检查服务状态是否存在
+ state_manager = self._store.registry._cache_state_manager
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ if not service_status:
+ raise DataSourceNotFoundError(
+ agent_id=agent_id,
+ service_name=service_name,
+ data_type="service_status"
+ )
+
+ logger.debug(
+ f"[TOOL_OPERATIONS] Verified data source ownership: "
+ f"agent_id={agent_id}, service={service_name}, "
+ f"service_global_name={service_global_name}"
+ )
+
+ def add_tools(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]],
+ tools: Union[List[str], Literal["_all_tools"]]
+ ) -> 'MCPStoreContext':
+ """
+ 添加工具到当前可用集合(同步版本)
+
+ 操作逻辑:
+ - 基于当前状态增量添加
+ - 明确指定工具名称
+ - 自动去重
+
+ Args:
+ service: 服务标识,支持三种类型:
+ - str: 服务名称,如 "weather"
+ - ServiceProxy: 服务代理对象,通过 find_service() 获取
+ - "_all_services": 保留字符串,表示所有服务
+
+ tools: 工具标识,支持两种类型:
+ - List[str]: 工具名称列表
+ * 具体名称: ["get_current", "get_forecast"]
+ - "_all_tools": 保留字符串,表示所有工具
+
+ Returns:
+ self (支持链式调用)
+
+ Raises:
+ ValueError: 如果在 Store 模式下调用
+
+ Examples:
+ # 1. 使用服务名称 + 工具列表
+ ctx.add_tools(service="weather", tools=["get_current", "get_forecast"])
+
+ # 2. 使用服务代理对象
+ weather_service = ctx.find_service("weather")
+ ctx.add_tools(service=weather_service, tools=["get_current"])
+
+ # 3. 使用 "_all_tools" 添加所有工具
+ ctx.add_tools(service="weather", tools="_all_tools")
+
+ # 4. 对所有服务添加工具
+ ctx.add_tools(service="_all_services", tools=["get_info"])
+
+ # 5. 链式调用
+ ctx.add_tools(service="weather", tools=["get_current"]) \\
+ .remove_tools(service="weather", tools=["get_history"])
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("add_tools() is only available in Agent mode")
+
+ return self._run_async_via_bridge(
+ self.add_tools_async(service, tools),
+ op_name="tool_operations.add_tools"
+ )
+
+ async def add_tools_async(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]],
+ tools: Union[List[str], Literal["_all_tools"]]
+ ) -> 'MCPStoreContext':
+ """
+ 添加工具到当前可用集合(异步版本)
+
+ 使用 StateManager 更新工具状态为 "available"。
+
+ Args:
+ service: 服务标识
+ tools: 工具标识
+
+ Returns:
+ self (支持链式调用)
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("add_tools() is only available in Agent mode")
+
+ # 解析服务参数
+ service_names = self._resolve_service(service)
+ if isinstance(service_names, str):
+ service_names = [service_names]
+
+ # 获取 StateManager
+ state_manager = self._store.registry._cache_state_manager
+
+ # 对每个服务执行添加操作
+ for service_name in service_names:
+ # 验证数据源归属
+ await self._verify_data_source_ownership(self._agent_id, service_name)
+
+ # 获取服务的全局名称(异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ self._agent_id, service_name
+ )
+
+ if not service_global_name:
+ raise RuntimeError(
+ f"Cannot get service global name: agent_id={self._agent_id}, "
+ f"service_name={service_name}"
+ )
+
+ # 获取服务状态
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ if not service_status:
+ raise RuntimeError(
+ f"Service status does not exist: service_global_name={service_global_name}"
+ )
+
+ # 确定要添加的工具列表
+ if tools == "_all_tools":
+ # 添加所有工具
+ tool_names = [t.tool_original_name for t in service_status.tools]
+ else:
+ tool_names = tools
+
+ # 批量设置工具为可用
+ await state_manager.batch_set_tools_status(
+ service_global_name,
+ tool_names,
+ "available"
+ )
+
+ logger.info(
+ f"Tools added successfully: agent_id={self._agent_id}, "
+ f"service={service_name}, tools={tool_names}"
+ )
+
+ return self
+
+ def remove_tools(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]],
+ tools: Union[List[str], Literal["_all_tools"]]
+ ) -> 'MCPStoreContext':
+ """
+ 从当前可用集合移除工具(同步版本)
+
+ 操作逻辑:
+ - 基于当前状态增量移除
+ - 明确指定工具名称
+ - 移除不存在的工具不报错
+
+ Args:
+ service: 服务标识,支持三种类型:
+ - str: 服务名称
+ - ServiceProxy: 服务代理对象
+ - "_all_services": 保留字符串,表示所有服务
+
+ tools: 工具标识,支持两种类型:
+ - List[str]: 工具名称列表
+ * 具体名称: ["get_history", "delete_cache"]
+ - "_all_tools": 保留字符串,清空所有工具
+
+ Returns:
+ self (支持链式调用)
+
+ Raises:
+ ValueError: 如果在 Store 模式下调用
+
+ Examples:
+ # 1. 移除具体工具
+ ctx.remove_tools(service="weather", tools=["get_history", "delete_cache"])
+
+ # 2. 移除多个工具
+ ctx.remove_tools(service="database", tools=["delete_table", "drop_table"])
+
+ # 3. 清空所有工具
+ ctx.remove_tools(service="weather", tools="_all_tools")
+
+ # 4. 从所有服务移除工具
+ ctx.remove_tools(service="_all_services", tools=["admin_panel"])
+
+ # 5. 典型用法: 先清空再添加(实现"只要部分工具")
+ ctx.remove_tools(service="weather", tools="_all_tools") \\
+ .add_tools(service="weather", tools=["get_current", "get_forecast"])
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("remove_tools() is only available in Agent mode")
+
+ return self._run_async_via_bridge(
+ self.remove_tools_async(service, tools),
+ op_name="tool_operations.remove_tools"
+ )
+
+ async def remove_tools_async(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]],
+ tools: Union[List[str], Literal["_all_tools"]]
+ ) -> 'MCPStoreContext':
+ """
+ 从当前可用集合移除工具(异步版本)
+
+ 使用 StateManager 更新工具状态为 "unavailable"。
+
+ Args:
+ service: 服务标识
+ tools: 工具标识
+
+ Returns:
+ self (支持链式调用)
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("remove_tools() is only available in Agent mode")
+
+ # 解析服务参数
+ service_names = self._resolve_service(service)
+ if isinstance(service_names, str):
+ service_names = [service_names]
+
+ # 获取 StateManager
+ state_manager = self._store.registry._cache_state_manager
+
+ # 对每个服务执行移除操作
+ for service_name in service_names:
+ # 验证数据源归属
+ await self._verify_data_source_ownership(self._agent_id, service_name)
+
+ # 获取服务的全局名称(异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ self._agent_id, service_name
+ )
+
+ if not service_global_name:
+ raise RuntimeError(
+ f"Cannot get service global name: agent_id={self._agent_id}, "
+ f"service_name={service_name}"
+ )
+
+ # 获取服务状态
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ if not service_status:
+ raise RuntimeError(
+ f"Service status does not exist: service_global_name={service_global_name}"
+ )
+
+ # 确定要移除的工具列表
+ if tools == "_all_tools":
+ # 移除所有工具
+ tool_names = [t.tool_original_name for t in service_status.tools]
+ else:
+ tool_names = tools
+
+ # 批量设置工具为不可用
+ await state_manager.batch_set_tools_status(
+ service_global_name,
+ tool_names,
+ "unavailable"
+ )
+
+ logger.info(
+ f"Tools removed successfully: agent_id={self._agent_id}, "
+ f"service={service_name}, tools={tool_names}"
+ )
+
+ return self
+
+ def reset_tools(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]]
+ ) -> 'MCPStoreContext':
+ """
+ 重置服务的工具集为默认状态(所有工具)(同步版本)
+
+ 操作逻辑:
+ - 恢复到服务初始化时的状态
+ - 等同于 add_tools(service, "_all_tools")
+
+ Args:
+ service: 服务标识,支持三种类型:
+ - str: 服务名称
+ - ServiceProxy: 服务代理对象
+ - "_all_services": 保留字符串,重置所有服务
+
+ Returns:
+ self (支持链式调用)
+
+ Raises:
+ ValueError: 如果在 Store 模式下调用
+
+ Examples:
+ # 1. 重置单个服务
+ ctx.reset_tools(service="weather")
+
+ # 2. 使用服务代理
+ weather_service = ctx.find_service("weather")
+ ctx.reset_tools(service=weather_service)
+
+ # 3. 重置所有服务
+ ctx.reset_tools(service="_all_services")
+
+ # 4. 等价于
+ ctx.add_tools(service="weather", tools="_all_tools")
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("reset_tools() is only available in Agent mode")
+
+ return self._run_async_via_bridge(
+ self.reset_tools_async(service),
+ op_name="tool_operations.reset_tools"
+ )
+
+ async def reset_tools_async(
+ self,
+ service: Union[str, 'ServiceProxy', Literal["_all_services"]]
+ ) -> 'MCPStoreContext':
+ """
+ 重置服务的工具集为默认状态(异步版本)
+
+ 将所有工具状态重置为 "available"。
+
+ Args:
+ service: 服务标识
+
+ Returns:
+ self (支持链式调用)
+ """
+ # 仅在 Agent 模式下生效
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("reset_tools() is only available in Agent mode")
+
+ # 解析服务参数
+ service_names = self._resolve_service(service)
+ if isinstance(service_names, str):
+ service_names = [service_names]
+
+ # 获取 StateManager
+ state_manager = self._store.registry._cache_state_manager
+
+ # 对每个服务执行重置操作
+ for service_name in service_names:
+ # 验证数据源归属
+ await self._verify_data_source_ownership(self._agent_id, service_name)
+
+ # 获取服务的全局名称(异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ self._agent_id, service_name
+ )
+
+ if not service_global_name:
+ raise RuntimeError(
+ f"Cannot get service global name: agent_id={self._agent_id}, "
+ f"service_name={service_name}"
+ )
+
+ # 获取服务状态
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ if not service_status:
+ raise RuntimeError(
+ f"Service status does not exist: service_global_name={service_global_name}"
+ )
+
+ # 获取所有工具名称
+ all_tool_names = [t.tool_original_name for t in service_status.tools]
+
+ # 批量设置所有工具为可用
+ if all_tool_names:
+ await state_manager.batch_set_tools_status(
+ service_global_name,
+ all_tool_names,
+ "available"
+ )
+
+ logger.info(
+ f"Tool set reset successfully: agent_id={self._agent_id}, "
+ f"service={service_name}, tools_count={len(all_tool_names)}"
+ )
+
+ return self
+
+ def get_tool_set_info(
+ self,
+ service: Union[str, 'ServiceProxy']
+ ) -> Dict[str, Any]:
+ """
+ 获取服务的工具集信息(同步版本)
+
+ Args:
+ service: 服务标识(服务名称或服务代理对象)
+
+ Returns:
+ 工具集信息字典
+
+ Raises:
+ ValueError: 如果在 Store 模式下调用
+
+ Examples:
+ info = ctx.get_tool_set_info(service="weather")
+ # {
+ # "service_name": "weather",
+ # "total_tools": 10,
+ # "available_tools": 5,
+ # "removed_tools": 5,
+ # "last_modified": 1234567890.0,
+ # "operations": [
+ # {"type": "remove", "tools": ["get_history"], "timestamp": ...},
+ # {"type": "add", "tools": ["get_forecast"], "timestamp": ...}
+ # ]
+ # }
+ """
+ # 仅在 Agent 模式下可用
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("get_tool_set_info() is only available in Agent mode")
+
+ return self._run_async_via_bridge(
+ self.get_tool_set_info_async(service),
+ op_name="tool_operations.get_tool_set_info"
+ )
+
+ async def get_tool_set_info_async(
+ self,
+ service: Union[str, 'ServiceProxy']
+ ) -> Dict[str, Any]:
+ """
+ 获取服务的工具集信息(异步版本)
+
+ 使用 StateManager 获取工具状态信息。
+
+ Args:
+ service: 服务标识(服务名称或服务代理对象)
+
+ Returns:
+ 工具集信息字典
+ """
+ # 仅在 Agent 模式下可用
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("get_tool_set_info() is only available in Agent mode")
+
+ # 解析服务名称
+ if hasattr(service, "name"):
+ service_name = service.name
+ else:
+ service_name = str(service)
+
+ # 获取服务的全局名称(异步版本,避免 AOB 事件循环冲突)
+ service_global_name = await self._store.registry.get_global_name_from_agent_service_async(
+ self._agent_id, service_name
+ )
+
+ if not service_global_name:
+ raise RuntimeError(
+ f"无法获取服务全局名称: agent_id={self._agent_id}, "
+ f"service_name={service_name}"
+ )
+
+ # 获取 StateManager
+ state_manager = self._store.registry._cache_state_manager
+
+ # 获取服务状态
+ service_status = await state_manager.get_service_status(service_global_name)
+
+ if not service_status:
+ raise RuntimeError(
+ f"服务状态不存在: service_global_name={service_global_name}"
+ )
+
+ # 计算统计信息
+ total_tools = len(service_status.tools)
+ available_tools = sum(
+ 1 for t in service_status.tools if t.status == "available"
+ )
+ unavailable_tools = total_tools - available_tools
+ utilization = available_tools / total_tools if total_tools > 0 else 0.0
+
+ # 构建工具列表
+ tools_info = [
+ {
+ "name": t.tool_original_name,
+ "global_name": t.tool_global_name,
+ "status": t.status
+ }
+ for t in service_status.tools
+ ]
+
+ return {
+ "service_name": service_name,
+ "service_global_name": service_global_name,
+ "health_status": service_status.health_status,
+ "total_tools": total_tools,
+ "available_tools": available_tools,
+ "unavailable_tools": unavailable_tools,
+ "utilization": round(utilization, 2),
+ "last_health_check": service_status.last_health_check,
+ "tools": tools_info
+ }
+
+ def get_tool_set_summary(self) -> Dict[str, Any]:
+ """
+ 获取工具集摘要(同步版本)
+
+ Returns:
+ 摘要信息字典
+
+ Raises:
+ ValueError: 如果在 Store 模式下调用
+
+ Examples:
+ summary = ctx.get_tool_set_summary()
+ # {
+ # "total_services": 3,
+ # "services": {
+ # "weather": {
+ # "total_tools": 10,
+ # "available_tools": 5,
+ # "utilization": 0.5
+ # },
+ # "database": {
+ # "total_tools": 20,
+ # "available_tools": 15,
+ # "utilization": 0.75
+ # }
+ # },
+ # "total_available_tools": 20,
+ # "total_original_tools": 30,
+ # "overall_utilization": 0.67
+ # }
+ """
+ # 仅在 Agent 模式下可用
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("get_tool_set_summary() is only available in Agent mode")
+
+ return self._run_async_via_bridge(
+ self.get_tool_set_summary_async(),
+ op_name="tool_operations.get_tool_set_summary"
+ )
+
+ async def get_tool_set_summary_async(self) -> Dict[str, Any]:
+ """
+ 获取工具集摘要(异步版本)
+
+ Returns:
+ 摘要信息字典
+ """
+ # 仅在 Agent 模式下可用
+ if self._context_type != ContextType.AGENT:
+ raise ValueError("get_tool_set_summary() is only available in Agent mode")
+
+ try:
+ # 获取所有服务
+ services = await self.list_services_async()
+ service_names = [getattr(s, "name", str(s)) for s in services]
+
+ # 获取每个服务的工具集信息
+ services_info = {}
+ total_available = 0
+ total_original = 0
+
+ for service_name in service_names:
+ try:
+ info = await self.get_tool_set_info_async(service_name)
+ services_info[service_name] = {
+ "total_tools": info.get("total_tools", 0),
+ "available_tools": info.get("available_tools", 0),
+ "utilization": info.get("utilization", 0.0)
+ }
+ total_available += info.get("available_tools", 0)
+ total_original += info.get("total_tools", 0)
+ except Exception as e:
+ logger.error(
+ f"Failed to get service tool set info: service={service_name}, error={e}"
+ )
+ raise
+
+ # 计算总体利用率
+ overall_utilization = total_available / total_original if total_original > 0 else 0.0
+
+ summary = {
+ "agent_id": self._agent_id,
+ "total_services": len(service_names),
+ "services": services_info,
+ "total_available_tools": total_available,
+ "total_original_tools": total_original,
+ "overall_utilization": round(overall_utilization, 2)
+ }
+
+ return summary
+
+ except Exception as e:
+ logger.error(
+ f"Failed to get tool set summary: agent_id={self._agent_id}, error={e}",
+ exc_info=True
+ )
+ raise
+ def _build_call_tool_error_result(self, message: str):
+ """
+ 构造与 FastMCP CallToolResult 接口兼容的错误对象。
+ """
+ text_block = mcp_types.TextContent(type="text", text=message)
+ failure = mcp_types.CallToolResult(
+ content=[text_block],
+ structuredContent=None,
+ isError=True,
+ )
+ setattr(failure, "structured_content", None)
+ setattr(failure, "data", None)
+ setattr(failure, "error", message)
+ setattr(failure, "is_error", True)
+ return failure
diff --git a/src/mcpstore/core/context/tool_proxy.py b/src/mcpstore/core/context/tool_proxy.py
new file mode 100644
index 00000000..3702043d
--- /dev/null
+++ b/src/mcpstore/core/context/tool_proxy.py
@@ -0,0 +1,623 @@
+"""
+MCPStore Tool Proxy Module
+工具代理对象,提供具体工具的操作方法
+"""
+
+import logging
+from datetime import datetime
+from typing import Dict, List, Optional, Any, TYPE_CHECKING
+
+from .types import ContextType
+
+if TYPE_CHECKING:
+ from ..models.tool import ToolInfo
+
+logger = logging.getLogger(__name__)
+
+
+class ToolCallResult:
+ """
+ 工具调用结果封装
+ 基于 FastMCP CallToolResult 提供友好接口
+ """
+
+ def __init__(self, fastmcp_result, tool_name: str, arguments: Dict[str, Any]):
+ """
+ 初始化工具调用结果
+
+ Args:
+ fastmcp_result: FastMCP 的 CallToolResult 对象
+ tool_name: 工具名称
+ arguments: 调用参数
+ """
+ self._result = fastmcp_result
+ self._tool_name = tool_name
+ self._arguments = arguments
+ self._called_at = datetime.now()
+
+ logger.debug(f"[TOOL_CALL_RESULT] Created for tool '{tool_name}', error={self.is_error}")
+
+ @property
+ def data(self):
+ """
+ FastMCP 的完全水合对象(核心特色)
+
+ Returns:
+ Any: 完全重构的 Python 对象,包括复杂类型如 datetime、UUID 等
+ """
+ return self._result.data if hasattr(self._result, 'data') else None
+
+ @property
+ def content(self):
+ """
+ 标准 MCP 内容块
+
+ Returns:
+ List: MCP 内容块列表 (TextContent, ImageContent 等)
+ """
+ return self._result.content if hasattr(self._result, 'content') else []
+
+ @property
+ def structured_content(self) -> Optional[Dict[str, Any]]:
+ """
+ 标准 MCP 结构化 JSON 数据
+
+ Returns:
+ Dict: 服务器发送的原始结构化数据
+ """
+ return getattr(self._result, 'structured_content', None)
+
+ @property
+ def is_error(self) -> bool:
+ """
+ 是否出错
+
+ Returns:
+ bool: True 表示工具执行失败
+ """
+ return getattr(self._result, 'is_error', False)
+
+ @property
+ def text_output(self) -> str:
+ """
+ 便捷的文本输出
+
+ Returns:
+ str: 工具的文本结果
+ """
+ if self.content and len(self.content) > 0:
+ first_content = self.content[0]
+ if hasattr(first_content, 'text'):
+ return first_content.text
+
+ # 如果没有文本内容,尝试从 data 获取
+ if self.data is not None:
+ return str(self.data)
+
+ return ""
+
+ @property
+ def tool_name(self) -> str:
+ """获取工具名称"""
+ return self._tool_name
+
+ @property
+ def arguments(self) -> Dict[str, Any]:
+ """获取调用参数"""
+ return self._arguments
+
+ @property
+ def called_at(self) -> datetime:
+ """获取调用时间"""
+ return self._called_at
+
+ def to_dict(self) -> Dict[str, Any]:
+ """
+ 转换为字典格式
+
+ Returns:
+ Dict: 包含所有结果信息的字典
+ """
+ return {
+ "tool_name": self.tool_name,
+ "arguments": self.arguments,
+ "called_at": self.called_at.isoformat(),
+ "is_error": self.is_error,
+ "data": self.data,
+ "text_output": self.text_output,
+ "has_structured_content": self.structured_content is not None
+ }
+
+ def __str__(self) -> str:
+ status = "ERROR" if self.is_error else "SUCCESS"
+ return f"ToolCallResult(tool='{self.tool_name}', status={status}, output='{self.text_output[:50]}...')"
+
+ def __repr__(self) -> str:
+ return self.__str__()
+
+ def find_cache(self) -> "CacheProxy":
+ from .cache_proxy import CacheProxy
+ return CacheProxy(self._context, scope="tool", scope_value=self._tool_name)
+
+
+class ToolProxy:
+ """
+ 工具代理对象
+ 提供具体工具的所有操作方法,进一步缩小作用域
+ """
+
+ def __init__(self, context: 'MCPStoreContext', tool_name: str,
+ scope: str = 'context', service_name: str = None):
+ """
+ 初始化工具代理
+
+ Args:
+ context: 父级上下文对象
+ tool_name: 工具名称
+ scope: 作用域类型 ('context' | 'service')
+ service_name: 服务名称 (当 scope='service' 时)
+ """
+ self._context = context
+ self._tool_name = tool_name
+ self._scope = scope
+ self._service_name = service_name
+ self._context_type = context.context_type
+ self._agent_id = context.agent_id
+ self._tool_info = None # 延迟加载
+ self._tool_info_obj: Optional['ToolInfo'] = None # 精准的 ToolInfo 对象缓存
+
+ logger.debug(f"[TOOL_PROXY] Created proxy for tool '{tool_name}' "
+ f"in {self._context_type.value} context, scope={scope}, service={service_name}")
+
+ @property
+ def tool_name(self) -> str:
+ """获取工具名称"""
+ return self._tool_name
+
+ @property
+ def context_type(self) -> ContextType:
+ """获取上下文类型"""
+ return self._context_type
+
+ @property
+ def scope(self) -> str:
+ """获取作用域类型"""
+ return self._scope
+
+ @property
+ def service_name(self) -> Optional[str]:
+ """获取关联的服务名称"""
+ return self._service_name
+
+ # === 工具信息查询方法(两个单词)===
+
+ def tool_info(self) -> Dict[str, Any]:
+ """
+ 获取工具详细信息(包括 FastMCP 的 meta 和 tags)
+
+ Returns:
+ Dict: 工具的完整信息,包括 FastMCP 特有的 meta 数据
+ """
+ if not self._tool_info:
+ self._load_tool_info()
+
+ return self._tool_info or {}
+
+ def tool_schema(self) -> Optional[Dict[str, Any]]:
+ """
+ 获取工具参数模式
+
+ Returns:
+ Dict: 工具的输入参数 schema
+ """
+ info = self.tool_info()
+ return info.get('inputSchema')
+
+ def tool_tags(self) -> List[str]:
+ """
+ 获取工具标签(基于 FastMCP meta._fastmcp.tags)
+
+ Returns:
+ List[str]: 工具标签列表
+ """
+ info = self.tool_info()
+ return info.get('tags', [])
+
+ def tool_meta(self) -> Dict[str, Any]:
+ """
+ 获取工具元数据
+
+ Returns:
+ Dict: 完整的 meta 数据
+ """
+ info = self.tool_info()
+ return info.get('meta', {})
+
+ # === FastMCP 视图 ===
+
+ def mcp_type2tool(self):
+ """
+ 将当前工具转换为 FastMCP 官方的 Tool 对象
+ """
+ from mcp import types as mcp_types
+
+ tool = self._get_tool_info_object()
+ if not tool:
+ # TODO: 工具命中机制待评估,避免模糊匹配导致返回错误的 FastMCP Tool。
+ raise ValueError(f"Tool '{self._tool_name}' not found; cannot build mcp.types.Tool")
+
+ meta = {
+ "service_name": tool.service_name,
+ "service_global_name": tool.service_global_name,
+ "client_id": tool.client_id,
+ }
+
+ return mcp_types.Tool(
+ name=tool.tool_original_name or tool.name,
+ title=getattr(tool, "title", None) or tool.name,
+ description=tool.description,
+ inputSchema=tool.inputSchema or {},
+ outputSchema=getattr(tool, "outputSchema", None),
+ icons=getattr(tool, "icons", None),
+ annotations=getattr(tool, "annotations", None),
+ _meta=meta,
+ )
+
+ # === 配置覆盖(如 LangChain return_direct) ===
+
+ def set_redirect(self, enabled: bool = True) -> 'ToolProxy':
+ """
+ 标记该工具为 "redirect" 行为(LangChain 中对应 return_direct)。
+
+ 当后续通过 context.for_langchain().list_tools() 转换为 LangChain 工具时,
+ 将读取该标记并设置到生成的 Tool/StructuredTool 上。
+ """
+ try:
+ # 1) 先尝试加载精确的工具信息
+ if not self._tool_info:
+ self._load_tool_info()
+
+ resolved_service = None
+ resolved_tool_name = None
+
+ if self._tool_info:
+ # 已经有精确匹配的信息
+ resolved_service = self._tool_info.get('service_name')
+ resolved_tool_name = self._tool_info.get('name', self._tool_name)
+ else:
+ # 2) 进行后缀匹配解析:支持传入简名(如 get_current_weather)
+ tools = self._context._run_async_via_bridge(
+ self._context.list_tools_async(),
+ op_name="tool_proxy.set_redirect.list_tools"
+ )
+ candidate = None
+
+ for t in tools:
+ # 限定服务匹配(如果指定了 service 范围)
+ if self._service_name and t.service_name != self._service_name:
+ continue
+
+ if t.name == self._tool_name:
+ candidate = t
+ break
+ # 支持下划线或双下划线分隔的后缀匹配
+ if t.name.endswith(f"_{self._tool_name}") or t.name.endswith(f"__{self._tool_name}"):
+ candidate = t
+ # 不立即 break,以便优先找到完全相同服务的匹配项(上面已按服务过滤)
+
+ if candidate:
+ resolved_service = candidate.service_name
+ resolved_tool_name = candidate.name
+ else:
+ # 保底:直接使用现有信息(可能覆盖不到正确键)
+ resolved_service = self._service_name or ""
+ resolved_tool_name = self._tool_name
+
+ # 3) 设置覆盖键(service:resolved_tool_name)
+ self._context._set_tool_override(resolved_service or "", resolved_tool_name, "return_direct", bool(enabled))
+ logger.debug(
+ f"[TOOL_PROXY] set_redirect(return_direct)={enabled} input='{self._tool_name}', "
+ f"resolved='{resolved_tool_name}', service='{resolved_service}'"
+ )
+ except Exception as e:
+ logger.warning(f"[TOOL_PROXY] set_redirect failed: {e}")
+ return self
+
+ # === 工具执行方法(两个单词)===
+
+ def call_tool(self, arguments: Dict[str, Any] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ 调用工具(同步版本)
+ 利用 FastMCP 的 call_tool() 和 CallToolResult
+
+ Args:
+ arguments: 工具参数字典
+ **kwargs: 额外的调用选项 (timeout, progress_handler 等)
+
+ Returns:
+ Any: FastMCP CallToolResult(或当 return_extracted=True 时返回已提取的数据)
+ """
+ return self._context._run_async_via_bridge(
+ self.call_tool_async(arguments, return_extracted=return_extracted, **kwargs),
+ op_name="tool_proxy.call_tool"
+ )
+
+ async def call_tool_async(self, arguments: Dict[str, Any] = None, return_extracted: bool = False, **kwargs) -> Any:
+ """
+ 调用工具(异步版本)
+
+ Args:
+ arguments: 工具参数字典
+ **kwargs: 额外的调用选项 (timeout, progress_handler 等)
+
+ Returns:
+ Any: FastMCP CallToolResult(或当 return_extracted=True 时返回已提取的数据)
+ """
+ arguments = arguments or {}
+ logger.info(f"[TOOL_PROXY] Calling tool '{self._tool_name}' with args: {arguments}")
+ return await self._context.call_tool_async(self._tool_name, arguments, return_extracted=return_extracted, **kwargs)
+
+ def test_call(self, arguments: Dict[str, Any] = None, return_extracted: bool = False) -> Any:
+ """
+ 测试调用工具(包含验证逻辑)
+
+ Args:
+ arguments: 测试参数
+
+ Returns:
+ Any: FastMCP CallToolResult(或当 return_extracted=True 时返回已提取的数据)
+ """
+ # 首先验证工具是否存在
+ info = self.tool_info()
+ if not info:
+ raise ValueError(f"Tool '{self._tool_name}' not found")
+
+ # 执行实际调用
+ return self.call_tool(arguments, return_extracted=return_extracted)
+
+ # === 工具统计方法(两个单词)===
+
+ def usage_stats(self) -> Dict[str, Any]:
+ """
+ 获取该工具的使用统计
+
+ Returns:
+ Dict: 工具使用统计信息
+ """
+ try:
+ # 通过监控系统获取工具统计
+ if hasattr(self._context, '_monitoring') and self._context._monitoring:
+ # 获取工具使用记录
+ records = self._context._monitoring.get_tool_records(limit=100)
+
+ # 过滤当前工具的记录(新结构键为 executions)
+ executions = records.get('executions', [])
+ tool_records = [
+ record for record in executions
+ if record.get('tool_name') == self._tool_name
+ ]
+ warning_msg = records.get('warning')
+
+ return {
+ "tool_name": self._tool_name,
+ "total_calls": len(tool_records),
+ "recent_calls": len([r for r in tool_records[-10:]]), # 最近10次
+ "success_rate": self._calculate_success_rate(tool_records),
+ "average_duration": self._calculate_average_duration(tool_records),
+ **({"warning": warning_msg} if warning_msg else {})
+ }
+ else:
+ return {
+ "tool_name": self._tool_name,
+ "total_calls": 0,
+ "recent_calls": 0,
+ "success_rate": 0.0,
+ "average_duration": 0.0,
+ "note": "Monitoring not available"
+ }
+ except Exception as e:
+ logger.error(f"[TOOL_PROXY] Failed to get usage stats: {e}")
+ return {
+ "tool_name": self._tool_name,
+ "error": str(e)
+ }
+
+ def call_history(self, limit: int = 10) -> List[Dict[str, Any]]:
+ """
+ 获取调用历史
+
+ Args:
+ limit: 返回记录数量限制
+
+ Returns:
+ List[Dict]: 调用历史记录
+ """
+ try:
+ if hasattr(self._context, '_monitoring') and self._context._monitoring:
+ records = self._context._monitoring.get_tool_records(limit=limit * 2) # 获取更多记录用于过滤
+
+ # 过滤当前工具的记录(新结构键为 executions)
+ executions = records.get('executions', [])
+ tool_records = [
+ record for record in executions
+ if record.get('tool_name') == self._tool_name
+ ]
+ if records.get('warning'):
+ tool_records.append({"warning": records.get('warning')})
+
+ # 返回最近的记录
+ return tool_records[:limit]
+ else:
+ return []
+ except Exception as e:
+ logger.error(f"[TOOL_PROXY] Failed to get call history: {e}")
+ return []
+
+ # === 内部辅助方法 ===
+
+ def _load_tool_info(self):
+ """延迟加载工具信息"""
+ try:
+ # 获取所有工具信息
+ tools = self._context._run_async_via_bridge(
+ self._context.list_tools_async(),
+ op_name="tool_proxy.load_tool_info.list_tools"
+ )
+
+ for tool in tools:
+ if tool.name == self._tool_name:
+ # 如果是服务范围,验证服务匹配
+ if self._scope == 'service' and self._service_name:
+ if tool.service_name != self._service_name:
+ continue
+
+ # 构建工具信息
+ info: Dict[str, Any] = {
+ 'name': tool.name,
+ 'description': tool.description,
+ 'inputSchema': tool.inputSchema,
+ 'service_name': tool.service_name,
+ 'client_id': tool.client_id,
+ 'tags': [],
+ 'meta': {},
+ 'scope': self._scope
+ }
+ # 1) 从 pykv 实体层补充 original_name/display_name(不使用快照)
+ try:
+ # 直接从 pykv 实体层获取工具实体
+ tool_entity_manager = self._context._store.registry._cache_tool_manager
+ tool_entity = self._context._run_async_via_bridge(
+ tool_entity_manager.get_tool(tool.name),
+ op_name="tool_proxy.load_tool_info.get_tool_entity"
+ )
+ if tool_entity:
+ entity_dict = tool_entity.to_dict() if hasattr(tool_entity, 'to_dict') else tool_entity
+ if 'tool_original_name' in entity_dict:
+ info['original_name'] = entity_dict.get('tool_original_name')
+ # display_name 默认使用 original_name
+ if 'original_name' in info:
+ info['display_name'] = info.get('original_name')
+ except Exception as e:
+ logger.debug(f"[TOOL_PROXY] pykv enrichment failed: {e}")
+
+ # 2) 从转换管理器补充 tags(如有)
+ try:
+ tm = getattr(self._context, '_transformation_manager', None)
+ if tm and hasattr(tm, 'transformer') and hasattr(tm.transformer, 'get_transformation_config'):
+ # 优先使用显示名(tool.name),其次 original_name
+ cfg = tm.transformer.get_transformation_config(tool.name)
+ if not cfg and isinstance(info.get('original_name'), str):
+ cfg = tm.transformer.get_transformation_config(info.get('original_name'))
+ if cfg and hasattr(cfg, 'tags') and isinstance(cfg.tags, list):
+ # 合并且去重
+ existing = set(info.get('tags') or [])
+ for t in cfg.tags:
+ if isinstance(t, str):
+ existing.add(t)
+ info['tags'] = list(existing)
+ except Exception as e:
+ logger.debug(f"[TOOL_PROXY] transformation tags enrichment failed: {e}")
+
+ # 3) 将 tags 写入 meta._fastmcp.tags,以兼容“FastMCP meta._fastmcp.tags”读取预期
+ try:
+ tags = info.get('tags') or []
+ meta = info.get('meta') or {}
+ if not isinstance(meta, dict):
+ meta = {}
+ fm = meta.get('_fastmcp') or {}
+ if not isinstance(fm, dict):
+ fm = {}
+ if isinstance(tags, list):
+ fm['tags'] = tags
+ meta['_fastmcp'] = fm
+ info['meta'] = meta
+ except Exception as e:
+ logger.debug(f"[TOOL_PROXY] meta/_fastmcp tags merge failed: {e}")
+
+ self._tool_info = info
+
+ break
+
+ if not self._tool_info:
+ logger.debug(f"[TOOL_PROXY] Tool '{self._tool_name}' not found in scope '{self._scope}'")
+
+ except Exception as e:
+ logger.error(f"[TOOL_PROXY] Failed to load tool info: {e}")
+
+ def _get_tool_info_object(self) -> Optional['ToolInfo']:
+ """
+ 获取完整的 ToolInfo 对象,用于构造 FastMCP Tool
+ """
+ if self._tool_info_obj is not None:
+ return self._tool_info_obj
+
+ try:
+ tools: List['ToolInfo'] = self._context._run_async_via_bridge(
+ self._context.list_tools_async(),
+ op_name="tool_proxy.get_tool_info_object.list_tools"
+ )
+ for tool in tools:
+ if self._service_name and tool.service_name != self._service_name:
+ continue
+
+ if (
+ tool.name == self._tool_name
+ or (tool.tool_original_name and tool.tool_original_name == self._tool_name)
+ or tool.name.endswith(f"_{self._tool_name}")
+ or tool.name.endswith(f"__{self._tool_name}")
+ ):
+ self._tool_info_obj = tool
+ break
+ except Exception as exc:
+ logger.error(f"[TOOL_PROXY] Failed to resolve ToolInfo object: {exc}")
+
+ return self._tool_info_obj or None
+
+ def _calculate_success_rate(self, records: List[Dict[str, Any]]) -> float:
+ """计算成功率"""
+ if not records:
+ return 0.0
+
+ success_count = sum(1 for record in records if not record.get('is_error', False))
+ return (success_count / len(records)) * 100.0
+
+ def _calculate_average_duration(self, records: List[Dict[str, Any]]) -> float:
+ """计算平均执行时间"""
+ if not records:
+ return 0.0
+
+ durations = [record.get('duration', 0.0) for record in records if 'duration' in record]
+ if not durations:
+ return 0.0
+
+ return sum(durations) / len(durations)
+
+ # === 便捷属性方法 ===
+
+ @property
+ def name(self) -> str:
+ """获取工具名称(便捷属性)"""
+ return self._tool_name
+
+ @property
+ def description(self) -> str:
+ """获取工具描述"""
+ info = self.tool_info()
+ return info.get('description', '')
+
+ @property
+ def has_schema(self) -> bool:
+ """是否有参数模式"""
+ return self.tool_schema() is not None
+
+ @property
+ def is_available(self) -> bool:
+ """工具是否可用"""
+ return bool(self.tool_info())
+
+ def __str__(self) -> str:
+ scope_info = f", service='{self._service_name}'" if self._scope == 'service' else ""
+ return f"ToolProxy(tool='{self._tool_name}', context='{self._context_type.value}', scope='{self._scope}'{scope_info})"
+
+ def __repr__(self) -> str:
+ return self.__str__()
diff --git a/src/mcpstore/core/context/tool_transformation.py b/src/mcpstore/core/context/tool_transformation.py
new file mode 100644
index 00000000..ced3f89c
--- /dev/null
+++ b/src/mcpstore/core/context/tool_transformation.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+"""
+Tool Transformation Functionality
+Based on FastMCP 2.8 tool transformation capabilities, providing LLM-friendly tool interfaces
+"""
+
+import logging
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Dict, List, Any, Optional, Callable
+
+logger = logging.getLogger(__name__)
+
+class TransformationType(Enum):
+ """Transformation types"""
+ RENAME_ARGS = "rename_args" # Rename parameters
+ HIDE_ARGS = "hide_args" # Hide parameters
+ MODIFY_DESCRIPTION = "modify_description" # Modify description
+ ADD_VALIDATION = "add_validation" # Add validation
+ SIMPLIFY_INTERFACE = "simplify_interface" # Simplify interface
+ ENHANCE_SAFETY = "enhance_safety" # Enhance safety
+
+@dataclass
+class ArgumentTransform:
+ """Argument transformation configuration"""
+ original_name: str
+ new_name: Optional[str] = None # New parameter name
+ hidden: bool = False # Whether to hide
+ default_value: Any = None # Default value
+ description: Optional[str] = None # New description
+ validation_fn: Optional[Callable] = None # Validation function
+ transform_fn: Optional[Callable] = None # Transformation function
+
+@dataclass
+class ToolTransformConfig:
+ """Tool transformation configuration"""
+ original_tool_name: str
+ new_tool_name: Optional[str] = None
+ new_description: Optional[str] = None
+ argument_transforms: Dict[str, ArgumentTransform] = field(default_factory=dict)
+ pre_execution_hooks: List[Callable] = field(default_factory=list)
+ post_execution_hooks: List[Callable] = field(default_factory=list)
+ tags: List[str] = field(default_factory=list)
+ enabled: bool = True
+
+class ToolTransformer:
+ """Tool transformer"""
+
+ def __init__(self):
+ self._transformations: Dict[str, ToolTransformConfig] = {}
+ self._original_tools: Dict[str, Any] = {}
+
+ def register_transformation(self, config: ToolTransformConfig) -> str:
+ """
+ 注册工具转换配置
+
+ Args:
+ config: 转换配置
+
+ Returns:
+ str: 转换后的工具名称
+ """
+ transformed_name = config.new_tool_name or f"{config.original_tool_name}_enhanced"
+ self._transformations[transformed_name] = config
+
+ logger.info(f"Registered tool transformation: {config.original_tool_name} -> {transformed_name}")
+ return transformed_name
+
+ def create_llm_friendly_tool(
+ self,
+ original_tool_name: str,
+ friendly_name: Optional[str] = None,
+ simplified_description: Optional[str] = None,
+ hide_technical_params: bool = True,
+ add_safety_checks: bool = True
+ ) -> str:
+ """
+ 创建 LLM 友好的工具版本
+
+ Args:
+ original_tool_name: 原始工具名
+ friendly_name: 友好名称
+ simplified_description: 简化描述
+ hide_technical_params: 是否隐藏技术参数
+ add_safety_checks: 是否添加安全检查
+
+ Returns:
+ str: 转换后的工具名称
+ """
+ config = ToolTransformConfig(
+ original_tool_name=original_tool_name,
+ new_tool_name=friendly_name or f"{original_tool_name}_simple",
+ new_description=simplified_description,
+ tags=["llm-friendly", "simplified"]
+ )
+
+ if hide_technical_params:
+ # 隐藏常见的技术参数
+ technical_params = ["timeout", "retry_count", "debug", "verbose", "raw_output"]
+ for param in technical_params:
+ config.argument_transforms[param] = ArgumentTransform(
+ original_name=param,
+ hidden=True,
+ default_value=self._get_default_for_param(param)
+ )
+
+ if add_safety_checks:
+ # 添加安全检查钩子
+ config.pre_execution_hooks.append(self._safety_check_hook)
+
+ return self.register_transformation(config)
+
+ def create_parameter_renamed_tool(
+ self,
+ original_tool_name: str,
+ parameter_mapping: Dict[str, str],
+ new_tool_name: Optional[str] = None
+ ) -> str:
+ """
+ 创建参数重命名的工具版本
+
+ Args:
+ original_tool_name: 原始工具名
+ parameter_mapping: 参数映射 {原参数名: 新参数名}
+ new_tool_name: 新工具名
+
+ Returns:
+ str: 转换后的工具名称
+ """
+ config = ToolTransformConfig(
+ original_tool_name=original_tool_name,
+ new_tool_name=new_tool_name or f"{original_tool_name}_renamed",
+ tags=["parameter-renamed"]
+ )
+
+ for original_param, new_param in parameter_mapping.items():
+ config.argument_transforms[original_param] = ArgumentTransform(
+ original_name=original_param,
+ new_name=new_param
+ )
+
+ return self.register_transformation(config)
+
+ def create_validated_tool(
+ self,
+ original_tool_name: str,
+ validation_rules: Dict[str, Callable],
+ new_tool_name: Optional[str] = None
+ ) -> str:
+ """
+ 创建带验证的工具版本
+
+ Args:
+ original_tool_name: 原始工具名
+ validation_rules: 验证规则 {参数名: 验证函数}
+ new_tool_name: 新工具名
+
+ Returns:
+ str: 转换后的工具名称
+ """
+ config = ToolTransformConfig(
+ original_tool_name=original_tool_name,
+ new_tool_name=new_tool_name or f"{original_tool_name}_validated",
+ tags=["validated", "safe"]
+ )
+
+ for param_name, validation_fn in validation_rules.items():
+ config.argument_transforms[param_name] = ArgumentTransform(
+ original_name=param_name,
+ validation_fn=validation_fn
+ )
+
+ return self.register_transformation(config)
+
+ def get_transformation_config(self, tool_name: str) -> Optional[ToolTransformConfig]:
+ """获取工具转换配置"""
+ return self._transformations.get(tool_name)
+
+ def list_transformed_tools(self) -> List[str]:
+ """列出所有转换后的工具"""
+ return list(self._transformations.keys())
+
+ def _get_default_for_param(self, param_name: str) -> Any:
+ """获取参数的默认值"""
+ defaults = {
+ "timeout": 30.0,
+ "retry_count": 3,
+ "debug": False,
+ "verbose": False,
+ "raw_output": False
+ }
+ return defaults.get(param_name)
+
+ def _safety_check_hook(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
+ """安全检查钩子"""
+ # 基本的安全检查
+ if not isinstance(args, dict):
+ raise ValueError("Arguments must be a dictionary")
+
+ # 检查危险参数
+ dangerous_keys = ["__", "eval", "exec", "import", "open", "file"]
+ for key in args:
+ if any(dangerous in str(key).lower() for dangerous in dangerous_keys):
+ logger.warning(f"Potentially dangerous parameter detected: {key}")
+
+ return args
+
+class ToolTransformationManager:
+ """工具转换管理器"""
+
+ def __init__(self):
+ self.transformer = ToolTransformer()
+ self._enabled_transformations: Dict[str, bool] = {}
+
+ def create_simple_weather_tool(self, original_tool_name: str) -> str:
+ """创建简化的天气工具"""
+ return self.transformer.create_llm_friendly_tool(
+ original_tool_name=original_tool_name,
+ friendly_name="get_weather",
+ simplified_description="Get current weather for a city. Just provide the city name.",
+ hide_technical_params=True,
+ add_safety_checks=True
+ )
+
+ def create_user_friendly_api_tool(self, original_tool_name: str, api_type: str) -> str:
+ """创建用户友好的 API 工具"""
+ friendly_names = {
+ "weather": "check_weather",
+ "news": "get_news",
+ "search": "search_web",
+ "translate": "translate_text",
+ "image": "process_image"
+ }
+
+ return self.transformer.create_llm_friendly_tool(
+ original_tool_name=original_tool_name,
+ friendly_name=friendly_names.get(api_type, f"use_{api_type}"),
+ simplified_description=f"Easy-to-use {api_type} tool with simplified parameters.",
+ hide_technical_params=True,
+ add_safety_checks=True
+ )
+
+ def enable_transformation(self, tool_name: str, enabled: bool = True):
+ """启用/禁用工具转换"""
+ self._enabled_transformations[tool_name] = enabled
+ logger.info(f"Tool transformation {tool_name} {'enabled' if enabled else 'disabled'}")
+
+ def is_transformation_enabled(self, tool_name: str) -> bool:
+ """检查工具转换是否启用"""
+ return self._enabled_transformations.get(tool_name, True)
+
+ def get_transformation_summary(self) -> Dict[str, Any]:
+ """获取转换摘要"""
+ return {
+ "total_transformations": len(self.transformer._transformations),
+ "enabled_transformations": sum(1 for enabled in self._enabled_transformations.values() if enabled),
+ "available_tools": self.transformer.list_transformed_tools(),
+ "transformation_types": [
+ "llm-friendly",
+ "parameter-renamed",
+ "validated",
+ "simplified"
+ ]
+ }
+
+# 全局实例
+_global_transformation_manager = None
+
+def get_transformation_manager() -> ToolTransformationManager:
+ """获取全局工具转换管理器"""
+ global _global_transformation_manager
+ if _global_transformation_manager is None:
+ _global_transformation_manager = ToolTransformationManager()
+ return _global_transformation_manager
diff --git a/src/mcpstore/core/context/types.py b/src/mcpstore/core/context/types.py
new file mode 100644
index 00000000..2ad7761d
--- /dev/null
+++ b/src/mcpstore/core/context/types.py
@@ -0,0 +1,11 @@
+"""
+MCPStore Context Types
+Context-related type definitions
+"""
+
+from enum import Enum
+
+class ContextType(Enum):
+ """Context type"""
+ STORE = "store"
+ AGENT = "agent"
diff --git a/src/mcpstore/core/domain/__init__.py b/src/mcpstore/core/domain/__init__.py
new file mode 100644
index 00000000..48010815
--- /dev/null
+++ b/src/mcpstore/core/domain/__init__.py
@@ -0,0 +1,29 @@
+"""
+领域层模块
+
+包含核心业务逻辑的领域服务:
+- CacheManager: 缓存管理
+- LifecycleManager: 生命周期管理
+- ConnectionManager: 连接管理
+- PersistenceManager: 持久化管理
+- HealthMonitor: 健康监控管理
+- ReconnectionScheduler: 重连调度管理
+"""
+
+from .cache_manager import CacheManager, CacheTransaction
+from .connection_manager import ConnectionManager
+from .health_monitor import HealthMonitor
+from .lifecycle_manager import LifecycleManager
+from .persistence_manager import PersistenceManager
+from .reconnection_scheduler import ReconnectionScheduler
+
+__all__ = [
+ "CacheManager",
+ "CacheTransaction",
+ "LifecycleManager",
+ "ConnectionManager",
+ "PersistenceManager",
+ "HealthMonitor",
+ "ReconnectionScheduler",
+]
+
diff --git a/src/mcpstore/core/domain/cache_manager.py b/src/mcpstore/core/domain/cache_manager.py
new file mode 100644
index 00000000..9f060f1b
--- /dev/null
+++ b/src/mcpstore/core/domain/cache_manager.py
@@ -0,0 +1,531 @@
+"""
+Cache Manager - Responsible for all cache operations
+
+Responsibilities:
+1. Listen to ServiceAddRequested events
+2. Add services to cache (transactional)
+3. Publish ServiceCached events
+4. Listen to ServiceConnected events, update cache
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass, field
+from typing import List, Callable
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceAddRequested,
+ ServiceBootstrapRequested,
+ ServiceBootstrapped,
+ ServiceBootstrapFailed,
+ ServiceCached,
+ ServiceConnected,
+ ServiceOperationFailed,
+)
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class CacheTransaction:
+ """Cache transaction - supports rollback"""
+ agent_id: str
+ operations: List[tuple[str, Callable, tuple]] = field(default_factory=list)
+
+ def record(self, operation_name: str, rollback_func: Callable, *args):
+ """Record operation (for rollback)"""
+ self.operations.append((operation_name, rollback_func, args))
+
+ async def rollback(self):
+ """Rollback all operations"""
+ logger.warning(f"Rolling back {len(self.operations)} cache operations for agent {self.agent_id}")
+ for op_name, rollback_func, args in reversed(self.operations):
+ try:
+ if asyncio.iscoroutinefunction(rollback_func):
+ await rollback_func(*args)
+ else:
+ rollback_func(*args)
+ logger.debug(f"Rolled back: {op_name}")
+ except Exception as e:
+ logger.error(f"Rollback failed for {op_name}: {e}")
+
+
+class CacheManager:
+ """
+ Cache Manager
+
+ Responsibilities:
+ 1. Listen to ServiceAddRequested events
+ 2. Add services to cache (transactional)
+ 3. Publish ServiceCached events
+ 4. Listen to ServiceConnected events, update cache
+ """
+
+ def __init__(self, event_bus: EventBus, registry: 'CoreRegistry', agent_locks: 'AgentLocks'):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._agent_locks = agent_locks
+
+ # Subscribe to events
+ self._event_bus.subscribe(ServiceAddRequested, self._on_service_add_requested, priority=100)
+ self._event_bus.subscribe(ServiceBootstrapRequested, self._on_service_bootstrap_requested, priority=100)
+ self._event_bus.subscribe(ServiceConnected, self._on_service_connected, priority=50)
+
+ logger.info("CacheManager initialized and subscribed to events")
+
+ async def _on_service_bootstrap_requested(self, event: ServiceBootstrapRequested):
+ """
+ 处理 setup/bootstrap 场景的服务重放:只构建缓存与关系,不触发阻塞链路
+ """
+ logger.info(f"[CACHE] [BOOTSTRAP] Processing ServiceBootstrapRequested: {event.service_name}")
+ logger.debug(f"[CACHE] [BOOTSTRAP] agent_id={event.agent_id}, client_id={event.client_id}, global_name={event.global_name}")
+ origin_agent = event.origin_agent_id or event.agent_id
+ origin_local_name = event.origin_local_name or event.service_name
+ global_name = event.global_name or event.service_name
+ global_agent_id = self._registry._naming.GLOBAL_AGENT_STORE
+
+ transaction = CacheTransaction(agent_id=origin_agent)
+
+ try:
+ async with self._agent_locks.write(
+ origin_agent,
+ operation="cache_on_service_bootstrap_requested"
+ ):
+ await self._registry._ensure_agent_entity(origin_agent)
+ await self._registry._ensure_agent_entity(global_agent_id)
+
+ # 写入服务实体与初始状态
+ await self._registry.add_service_async(
+ agent_id=global_agent_id,
+ name=global_name,
+ session=None,
+ tools=[],
+ service_config=event.service_config,
+ state=ServiceConnectionState.INITIALIZING
+ )
+ transaction.record(
+ "add_service_global_bootstrap",
+ self._registry.remove_service_async,
+ global_agent_id, global_name
+ )
+
+ # 建立 Agent-Service 关系
+ await self._registry._relation_manager.add_agent_service(
+ agent_id=origin_agent,
+ service_original_name=origin_local_name,
+ service_global_name=global_name,
+ client_id=event.client_id
+ )
+ transaction.record(
+ "add_agent_service_bootstrap",
+ self._registry._relation_manager.remove_agent_service,
+ origin_agent, global_name
+ )
+
+ # 设置 service-client 映射(双向)
+ await self._registry.set_service_client_mapping_async(origin_agent, origin_local_name, event.client_id)
+ transaction.record(
+ "set_service_client_mapping_origin_bootstrap",
+ self._registry.remove_service_client_mapping,
+ origin_agent, origin_local_name
+ )
+ await self._registry.set_service_client_mapping_async(global_agent_id, global_name, event.client_id)
+ transaction.record(
+ "set_service_client_mapping_global_bootstrap",
+ self._registry.remove_service_client_mapping,
+ global_agent_id, global_name
+ )
+
+ # 发布 bootstrap 完成事件,交给生命周期与健康组件后台处理
+ bootstrapped = ServiceBootstrapped(
+ agent_id=origin_agent,
+ service_name=origin_local_name,
+ client_id=event.client_id,
+ global_name=global_name,
+ source=event.source,
+ service_config=event.service_config
+ )
+ await self._event_bus.publish(bootstrapped, wait=False)
+
+ except Exception as e:
+ logger.error(f"[CACHE] [BOOTSTRAP] Failed to cache service {event.service_name}: {e}", exc_info=True)
+ await transaction.rollback()
+
+ failed_event = ServiceBootstrapFailed(
+ agent_id=origin_agent,
+ service_name=origin_local_name,
+ error_message=str(e),
+ source=event.source,
+ original_event=event
+ )
+ try:
+ await self._event_bus.publish(failed_event, wait=False)
+ except Exception as pub_err:
+ logger.error(f"[CACHE] [BOOTSTRAP] Failed to publish ServiceBootstrapFailed: {pub_err}")
+ return
+
+ async def _on_service_add_requested(self, event: ServiceAddRequested):
+ """
+ Handle service add request - immediately add to cache
+ """
+ logger.info(f"[CACHE] Processing ServiceAddRequested: {event.service_name}")
+ logger.debug(f"[CACHE] Event details: agent_id={event.agent_id}, client_id={event.client_id}, global_name={getattr(event, 'global_name', '')}")
+ logger.debug(f"[CACHE] Service config keys: {list(event.service_config.keys()) if event.service_config else 'None'}")
+ origin_agent = event.origin_agent_id or event.agent_id
+ origin_local_name = event.origin_local_name or event.service_name
+ global_name = event.global_name or event.service_name
+ global_agent_id = self._registry._naming.GLOBAL_AGENT_STORE
+
+ transaction = CacheTransaction(agent_id=origin_agent)
+
+ try:
+ # 使用 per-agent 锁保证并发安全
+ async with self._agent_locks.write(
+ origin_agent,
+ operation="cache_on_service_add_requested"
+ ):
+ # 确保 Agent 实体存在(来源 Agent + 全局 Agent)
+ await self._registry._ensure_agent_entity(origin_agent)
+ await self._registry._ensure_agent_entity(global_agent_id)
+
+ # 1. 添加服务到缓存(全局视角,INITIALIZING 状态)
+ await self._registry.add_service_async(
+ agent_id=global_agent_id,
+ name=global_name,
+ session=None, # 暂无连接
+ tools=[], # 暂无工具
+ service_config=event.service_config,
+ state=ServiceConnectionState.INITIALIZING
+ )
+ transaction.record(
+ "add_service_global",
+ self._registry.remove_service_async,
+ global_agent_id, global_name
+ )
+
+ # 2. 建立 Agent-Service 关系
+ await self._registry._relation_manager.add_agent_service(
+ agent_id=origin_agent,
+ service_original_name=origin_local_name,
+ service_global_name=global_name,
+ client_id=event.client_id
+ )
+ transaction.record(
+ "add_agent_service",
+ self._registry._relation_manager.remove_agent_service,
+ origin_agent, global_name
+ )
+
+ # 3. 添加 Service-Client 映射(Agent 与 Global)
+ logger.debug(f"[CACHE] Adding service-client mapping: {origin_agent}:{origin_local_name} -> {event.client_id}")
+ await self._registry.set_service_client_mapping_async(
+ origin_agent, origin_local_name, event.client_id
+ )
+ await self._registry.set_service_client_mapping_async(
+ global_agent_id, global_name, event.client_id
+ )
+ transaction.record(
+ "set_service_client_mapping_agent",
+ self._registry.delete_service_client_mapping_async,
+ origin_agent, origin_local_name
+ )
+ transaction.record(
+ "set_service_client_mapping_global",
+ self._registry.delete_service_client_mapping_async,
+ global_agent_id, global_name
+ )
+
+ # 立即验证映射是否成功建立(使用异步版本)
+ verify_client_id = await self._registry.get_service_client_id_async(origin_agent, origin_local_name)
+ if verify_client_id != event.client_id:
+ error_msg = (
+ f"Service-client mapping verification failed! "
+ f"Expected: {event.client_id}, Got: {verify_client_id}"
+ )
+ logger.error(f"[CACHE] {error_msg}")
+ raise RuntimeError(error_msg)
+ logger.debug(f"[CACHE] Service-client mapping verified: {origin_agent}:{origin_local_name} -> {verify_client_id}")
+
+ logger.info(f"[CACHE] Service cached: {event.service_name}")
+ logger.debug(f"[CACHE] Verification - client_id mapping: {verify_client_id}")
+
+ # 注意:在新架构中,client_config 不再单独存储
+ # 服务配置已经存储在服务实体中(service_entity.config)
+
+ # 发布成功事件
+ cached_event = ServiceCached(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ client_id=event.client_id,
+ cache_keys=[
+ f"service:{event.agent_id}:{event.service_name}",
+ f"agent_client:{event.agent_id}:{event.client_id}",
+ f"client_config:{event.client_id}",
+ f"service_client:{event.agent_id}:{event.service_name}"
+ ]
+ )
+ logger.info(f"[CACHE] Publishing ServiceCached event for {event.service_name}")
+ await self._event_bus.publish(cached_event)
+
+ # 仅负责缓存与事件发布;连接请求由 orchestrator/connection_manager 统一触发
+
+ except Exception as e:
+ logger.error(f"[CACHE] Failed to cache service {event.service_name}: {e}", exc_info=True)
+
+ # 回滚事务
+ await transaction.rollback()
+
+ # 发布失败事件
+ error_event = ServiceOperationFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ operation="cache",
+ error_message=str(e),
+ original_event=event
+ )
+ await self._event_bus.publish(error_event)
+
+ async def _on_service_connected(self, event: ServiceConnected):
+ """
+ 处理服务连接成功 - 更新缓存中的 session 和 tools
+
+ 关键职责:
+ 1. 更新服务的 session
+ 2. 创建工具实体(写入实体层)
+ 3. 创建 Service-Tool 关系(写入关系层)
+ 4. 更新服务状态(写入状态层)
+ """
+ logger.info(f"[CACHE] Updating cache for connected service: {event.service_name}")
+
+ try:
+ async with self._agent_locks.write(
+ event.agent_id,
+ operation="cache_on_service_connected"
+ ):
+ # 从 pykv 读取现有服务配置(保持配置不丢失)
+ # 这是关键:ServiceConnected 事件中没有 service_config 字段,
+ # 必须从 pykv 读取已有配置,否则 add_service_async 会用空字典覆盖
+ service_global_name = self._registry._naming.generate_service_global_name(
+ event.service_name, event.agent_id
+ )
+ service_entity = await self._registry._cache_service_manager.get_service(
+ service_global_name
+ )
+ if service_entity is None:
+ raise RuntimeError(
+ f"Service entity does not exist, cannot update cache: "
+ f"service_name={event.service_name}, agent_id={event.agent_id}, "
+ f"global_name={service_global_name}"
+ )
+ existing_config = service_entity.config
+ if not existing_config:
+ raise RuntimeError(
+ f"Service configuration is empty, data inconsistency: "
+ f"service_name={event.service_name}, agent_id={event.agent_id}, "
+ f"global_name={service_global_name}"
+ )
+
+ # 清理旧的工具缓存(如果存在)
+ existing_session = self._registry.get_session(event.agent_id, event.service_name)
+ if existing_session:
+ self._registry.clear_service_tools_only(event.agent_id, event.service_name)
+
+ # 更新会话(不触发新增服务的初始化逻辑)
+ if self._registry._session_manager:
+ self._registry._session_manager.set_session(
+ event.agent_id, event.service_name, event.session
+ )
+
+ # 创建工具实体和 Service-Tool 关系(写入实体层和关系层)
+ # 这是 list_tools 链路能正确获取工具的关键
+ await self._create_tool_entities_and_relations(
+ event.agent_id,
+ event.service_name,
+ event.tools
+ )
+
+ # 更新服务状态(写入状态层)
+ # 关键:这里写入完整的工具状态,LifecycleManager 只更新健康状态
+ await self._update_service_status(
+ event.agent_id,
+ event.service_name,
+ event.tools
+ )
+
+ logger.info(f"[CACHE] Cache updated for {event.service_name} with {len(event.tools)} tools")
+
+ except Exception as e:
+ logger.error(f"[CACHE] Failed to update cache for {event.service_name}: {e}", exc_info=True)
+
+ # 发布失败事件
+ error_event = ServiceOperationFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ operation="cache_update",
+ error_message=str(e),
+ original_event=event
+ )
+ await self._event_bus.publish(error_event)
+
+ async def _create_tool_entities_and_relations(
+ self,
+ agent_id: str,
+ service_name: str,
+ tools: list
+ ) -> None:
+ """
+ 创建工具实体和 Service-Tool 关系
+
+ 写入实体层和关系层,这是 list_tools 链路能正确获取工具的关键。
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ tools: 工具列表 [(tool_name, tool_def), ...]
+
+ Raises:
+ RuntimeError: 如果必要的管理器未初始化
+ """
+ # 获取服务的全局名称
+ service_global_name = self._registry._naming.generate_service_global_name(
+ service_name, agent_id
+ )
+
+ logger.info(
+ f"[CACHE] Creating tool entities and relations: agent_id={agent_id}, "
+ f"service_name={service_name}, service_global_name={service_global_name}, "
+ f"tools_count={len(tools)}"
+ )
+
+ # 获取必要的管理器
+ tool_entity_manager = self._registry._cache_tool_manager
+ relation_manager = self._registry._relation_manager
+
+ if tool_entity_manager is None:
+ raise RuntimeError(
+ f"ToolEntityManager not initialized, cannot create tool entities: "
+ f"service_global_name={service_global_name}"
+ )
+
+ if relation_manager is None:
+ raise RuntimeError(
+ f"RelationshipManager not initialized, cannot create Service-Tool relation: "
+ f"service_global_name={service_global_name}"
+ )
+
+ # 遍历工具列表,创建实体和关系
+ for tool_name, tool_def in tools:
+ # 提取工具原始名称(去除服务前缀),保证用于全局名的基准是 FastMCP 标准格式
+ from mcpstore.core.logic.tool_logic import ToolLogicCore
+ original_tool_name = ToolLogicCore.extract_original_tool_name(
+ tool_name,
+ service_global_name,
+ service_name
+ )
+
+ # 生成工具全局名称(基于去前缀后的原始名,避免重复前缀)
+ tool_global_name = self._registry._naming.generate_tool_global_name(
+ service_global_name, original_tool_name
+ )
+
+ logger.debug(
+ f"[CACHE] Creating tool: tool_name={tool_name}, "
+ f"tool_global_name={tool_global_name}, original={original_tool_name}"
+ )
+
+ # 1. 创建工具实体(写入实体层)
+ await tool_entity_manager.create_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_original_name=original_tool_name,
+ tool_def=tool_def
+ )
+
+ # 2. 创建 Service-Tool 关系(写入关系层)
+ await relation_manager.add_service_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_global_name=tool_global_name,
+ tool_original_name=original_tool_name
+ )
+
+ logger.info(
+ f"[CACHE] Tool entities and relations created successfully: service_global_name={service_global_name}, "
+ f"tools_count={len(tools)}"
+ )
+
+ async def _update_service_status(
+ self,
+ agent_id: str,
+ service_name: str,
+ tools: list
+ ) -> None:
+ """
+ 更新服务状态到 pykv 状态层
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ tools: 工具列表 [(tool_name, tool_def), ...]
+
+ Raises:
+ RuntimeError: 如果 CacheStateManager 未初始化
+ """
+ # 获取服务的全局名称
+ service_global_name = self._registry._naming.generate_service_global_name(
+ service_name, agent_id
+ )
+
+ logger.debug(
+ f"[CACHE] Updating service status: agent_id={agent_id}, "
+ f"service_name={service_name}, service_global_name={service_global_name}, "
+ f"tools_count={len(tools)}"
+ )
+
+ # 构建工具状态列表(所有工具默认 available)
+ tools_status = []
+ for tool_name, tool_def in tools:
+ # 提取工具原始名称(去除服务前缀)
+ # 注意:MCP 服务返回的工具名称可能已经带有服务前缀
+ # 例如:mcpstore_get_current_weather -> get_current_weather
+ from mcpstore.core.logic.tool_logic import ToolLogicCore
+ original_tool_name = ToolLogicCore.extract_original_tool_name(
+ tool_name,
+ service_global_name,
+ service_name
+ )
+
+ # 生成工具全局名称(基于去前缀后的原始名,避免重复前缀)
+ tool_global_name = self._registry._naming.generate_tool_global_name(
+ service_global_name, original_tool_name
+ )
+
+ tools_status.append({
+ "tool_global_name": tool_global_name,
+ "tool_original_name": original_tool_name,
+ "status": "available"
+ })
+
+ # 获取 CacheStateManager(pykv 唯一真相数据源)
+ state_manager = self._registry._cache_state_manager
+
+ if state_manager is None:
+ raise RuntimeError(
+ f"CacheStateManager not initialized, cannot update service status: "
+ f"service_global_name={service_global_name}"
+ )
+
+ await state_manager.update_service_status(
+ service_global_name=service_global_name,
+ health_status="healthy",
+ tools_status=tools_status
+ )
+
+ logger.info(
+ f"[CACHE] Service status updated successfully: service_global_name={service_global_name}, "
+ f"tools_count={len(tools_status)}"
+ )
diff --git a/src/mcpstore/core/domain/connection_manager.py b/src/mcpstore/core/domain/connection_manager.py
new file mode 100644
index 00000000..52561b35
--- /dev/null
+++ b/src/mcpstore/core/domain/connection_manager.py
@@ -0,0 +1,332 @@
+"""
+Connection Manager - Responsible for actual service connections
+
+Responsibilities:
+1. Listen to ServiceInitialized events, trigger connections
+2. Execute actual service connections (local/remote)
+3. Publish ServiceConnected/ServiceConnectionFailed events
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any, Tuple, List
+
+from mcpstore.core.configuration.config_processor import ConfigProcessor
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceInitialized, ServiceConnectionRequested,
+ ServiceConnected, ServiceConnectionFailed
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ConnectionManager:
+ """
+ Connection Manager
+
+ Responsibilities:
+ 1. Listen to ServiceInitialized events, trigger connections
+ 2. Execute actual service connections (local/remote)
+ 3. Publish ServiceConnected/ServiceConnectionFailed events
+ """
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ registry: 'CoreRegistry',
+ config_processor: 'ConfigProcessor',
+ local_service_manager: 'LocalServiceManagerAdapter',
+ http_timeout_seconds: float = 10.0
+ ):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._config_processor = config_processor
+ self._local_service_manager = local_service_manager
+ self._http_timeout_seconds = http_timeout_seconds
+
+ # Subscribe to events
+ self._event_bus.subscribe(ServiceInitialized, self._on_service_initialized, priority=80)
+ self._event_bus.subscribe(ServiceConnectionRequested, self._on_connection_requested, priority=100)
+
+ # New: subscribe to reconnection request events
+ from mcpstore.core.events.service_events import ReconnectionRequested
+ self._event_bus.subscribe(ReconnectionRequested, self._on_reconnection_requested, priority=100)
+
+ logger.info(f"ConnectionManager initialized (bus={hex(id(self._event_bus))}) and subscribed to events")
+ logger.debug(f"[CONNECTION] HTTP timeout configured: {self._http_timeout_seconds} seconds")
+
+ async def _on_service_initialized(self, event: ServiceInitialized):
+ """
+ Handle service initialization completion - trigger connection
+
+ ServiceInitialized 表示缓存和生命周期元数据已经写入,此时才发布连接事件。
+ """
+ logger.info(f"[CONNECTION] Triggering connection for: {event.service_name} (from ServiceInitialized)")
+
+ # Get service configuration(使用异步版本)
+ service_config = await self._get_service_config_async(event.agent_id, event.service_name)
+ if not service_config:
+ logger.error(f"[CONNECTION] No config found for {event.service_name}")
+ return
+
+ # Diagnostics: check subscriber count for ServiceConnectionRequested
+ try:
+ sub_cnt = self._event_bus.get_subscriber_count(ServiceConnectionRequested)
+ logger.debug(f"[CONNECTION] Bus {hex(id(self._event_bus))} ServiceConnectionRequested subscribers={sub_cnt}")
+ except Exception as e:
+ logger.debug(f"[CONNECTION] Subscriber count check failed: {e}")
+
+ # Use configured timeout instead of hardcoded value
+ timeout = self._http_timeout_seconds
+ logger.debug(f"[CONNECTION] Using configured HTTP timeout: {timeout} seconds for {event.service_name}")
+
+ # Publish connection request event (decoupled)
+ connection_request = ServiceConnectionRequested(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ service_config=service_config,
+ timeout=timeout
+ )
+ # Use synchronous dispatch to avoid event-loop race during restart/initialization
+ await self._event_bus.publish(connection_request, wait=True)
+
+ async def _on_connection_requested(self, event: ServiceConnectionRequested):
+ """
+ Handle connection request - execute actual connection
+ """
+ logger.info(f"[CONNECTION] Connecting to: {event.service_name} (bus={hex(id(self._event_bus))}, timeout={event.timeout}s)")
+
+ start_time = asyncio.get_event_loop().time()
+
+ try:
+ # Determine service type
+ if "command" in event.service_config:
+ # Local service
+ session, tools = await self._connect_local_service(
+ event.service_name, event.service_config, event.timeout
+ )
+ else:
+ # Remote service
+ session, tools = await self._connect_remote_service(
+ event.service_name, event.service_config, event.timeout
+ )
+
+ connection_time = asyncio.get_event_loop().time() - start_time
+
+ logger.info(
+ f"[CONNECTION] Connected: {event.service_name} "
+ f"({len(tools)} tools, {connection_time:.2f}s)"
+ )
+
+ # Publish connection success event
+ connected_event = ServiceConnected(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ session=session,
+ tools=tools,
+ connection_time=connection_time
+ )
+ await self._event_bus.publish(connected_event)
+
+ except asyncio.TimeoutError:
+ elapsed = asyncio.get_event_loop().time() - start_time
+ logger.warning(
+ f"[CONNECTION] Timeout: {event.service_name} "
+ f"(configured={event.timeout}s, elapsed={elapsed:.3f}s)"
+ )
+ await self._publish_connection_failed(
+ event, "Connection timeout", "timeout", 0
+ )
+
+ except Exception as e:
+ # Demote expected network/connectivity errors to WARNING and show friendly message
+ network_error = False
+ try:
+ import httpx # type: ignore
+ if isinstance(e, getattr(httpx, "ConnectError", tuple())) or isinstance(e, getattr(httpx, "ReadTimeout", tuple())):
+ network_error = True
+ except Exception:
+ pass
+ text = str(e)
+ if ("all connection attempts failed" in text.lower()) or ("timed out" in text.lower()) or ("certificate" in text.lower()) or ("handshake failure" in text.lower()):
+ network_error = True
+
+ # Convert to user-friendly message
+ try:
+ friendly = ConfigProcessor.get_user_friendly_error(text)
+ except Exception:
+ friendly = text
+
+ if network_error:
+ logger.warning(f"[CONNECTION] Failed: {event.service_name} - {friendly}")
+ else:
+ logger.error(f"[CONNECTION] Failed: {event.service_name} - {friendly}", exc_info=True)
+ await self._publish_connection_failed(
+ event, text, "connection_error", 0
+ )
+
+ async def _connect_local_service(
+ self,
+ service_name: str,
+ service_config: Dict[str, Any],
+ timeout: float
+ ) -> Tuple[Any, List[Tuple[str, Dict[str, Any]]]]:
+ """Connect to local service"""
+ from fastmcp import Client
+
+ # 1. Process configuration
+ processed_config = self._config_processor.process_user_config_for_fastmcp({
+ "mcpServers": {service_name: service_config}
+ })
+
+ # 2. Create client and connect(FastMCP Client 会在 async with 中自动启动本地进程)
+ client = Client(processed_config)
+
+ async with asyncio.timeout(timeout):
+ async with client:
+ tools_list = await client.list_tools()
+ processed_tools = self._process_tools(service_name, tools_list)
+ return client, processed_tools
+
+ async def _connect_remote_service(
+ self,
+ service_name: str,
+ service_config: Dict[str, Any],
+ timeout: float
+ ) -> Tuple[Any, List[Tuple[str, Dict[str, Any]]]]:
+ """Connect to remote service"""
+ from fastmcp import Client
+
+ # 1. Process configuration
+ processed_config = self._config_processor.process_user_config_for_fastmcp({
+ "mcpServers": {service_name: service_config}
+ })
+
+ # 2. Create client and connect
+ client = Client(processed_config)
+
+ async with asyncio.timeout(timeout):
+ async with client:
+ tools_list = await client.list_tools()
+ processed_tools = self._process_tools(service_name, tools_list)
+ return client, processed_tools
+
+ def _process_tools(
+ self,
+ service_name: str,
+ tools_list: List[Any]
+ ) -> List[Tuple[str, Dict[str, Any]]]:
+ """Process tool list"""
+ processed_tools = []
+
+ for tool in tools_list:
+ try:
+ original_name = tool.name
+ display_name = f"{service_name}_{original_name}"
+
+ # Process parameters
+ parameters = {}
+ if hasattr(tool, 'inputSchema') and tool.inputSchema:
+ if hasattr(tool.inputSchema, 'model_dump'):
+ parameters = tool.inputSchema.model_dump()
+ elif isinstance(tool.inputSchema, dict):
+ parameters = tool.inputSchema
+
+ # Build tool definition
+ tool_def = {
+ "type": "function",
+ "function": {
+ "name": original_name,
+ "display_name": display_name,
+ "description": tool.description if hasattr(tool, 'description') else "",
+ "parameters": parameters,
+ "service_name": service_name
+ }
+ }
+
+ processed_tools.append((display_name, tool_def))
+
+ except Exception as e:
+ logger.error(f"Failed to process tool {tool.name}: {e}")
+ continue
+
+ return processed_tools
+
+ async def _publish_connection_failed(
+ self,
+ event: ServiceConnectionRequested,
+ error_message: str,
+ error_type: str,
+ retry_count: int
+ ):
+ """Publish connection failed event"""
+ try:
+ friendly_message = ConfigProcessor.get_user_friendly_error(error_message or "")
+ except Exception:
+ friendly_message = error_message
+ failed_event = ServiceConnectionFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ error_message=friendly_message,
+ error_type=error_type,
+ retry_count=retry_count
+ )
+ # 关键修复:使用 wait=True 确保状态更新完成,避免任务被取消导致状态不更新
+ await self._event_bus.publish(failed_event, wait=True)
+
+ async def _on_reconnection_requested(self, event: 'ReconnectionRequested'):
+ """
+ Handle reconnection request - trigger connection again
+ """
+ logger.info(f"[CONNECTION] Reconnection requested: {event.service_name} (retry={event.retry_count})")
+
+ # Get service configuration(使用异步版本)
+ service_config = await self._get_service_config_async(event.agent_id, event.service_name)
+ if not service_config:
+ logger.error(f"[CONNECTION] No config found for reconnection: {event.service_name}")
+ return
+
+ # Publish connection request event (reuse existing connection logic)
+ connection_request = ServiceConnectionRequested(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ service_config=service_config,
+ timeout=5.0 # Use longer timeout for reconnection
+ )
+ await self._event_bus.publish(connection_request, wait=True)
+
+ async def _get_service_config_async(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 从服务实体中获取服务配置(异步版本)
+
+ 在新架构中,服务配置存储在服务实体中(service_entity.config),
+ 不再从 client_config 中获取。
+ """
+ logger.debug(f"[CONNECTION] Getting config for {agent_id}:{service_name}")
+
+ # 生成服务全局名称
+ service_global_name = self._registry._naming.generate_service_global_name(
+ service_name, agent_id
+ )
+
+ # 从 pykv 获取服务实体
+ service_entity = await self._registry._cache_service_manager.get_service(
+ service_global_name
+ )
+
+ if service_entity is None:
+ raise RuntimeError(
+ f"Service entity does not exist: service_name={service_name}, "
+ f"agent_id={agent_id}, global_name={service_global_name}"
+ )
+
+ service_config = service_entity.config
+ if not service_config:
+ raise RuntimeError(
+ f"Service configuration is empty: service_name={service_name}, "
+ f"agent_id={agent_id}, global_name={service_global_name}"
+ )
+
+ logger.debug(f"[CONNECTION] Found config for {service_name}: {list(service_config.keys())}")
+ return service_config
diff --git a/src/mcpstore/core/domain/health_monitor.py b/src/mcpstore/core/domain/health_monitor.py
new file mode 100644
index 00000000..13da2a8f
--- /dev/null
+++ b/src/mcpstore/core/domain/health_monitor.py
@@ -0,0 +1,444 @@
+"""
+健康检查管理器 - 负责服务健康监控
+
+职责:
+1. 监听 ServiceConnected 事件,启动定期健康检查
+2. 定期检查服务健康状态
+3. 发布 HealthCheckCompleted 事件
+4. 检测服务超时
+"""
+
+import asyncio
+import logging
+import time
+from typing import Dict, Tuple
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceConnected, HealthCheckRequested, HealthCheckCompleted,
+ ServiceTimeout, ServiceStateChanged
+)
+from mcpstore.core.lifecycle.config import ServiceLifecycleConfig
+from mcpstore.core.models.service import ServiceConnectionState
+from mcpstore.core.utils.mcp_client_helpers import temp_client_for_service
+
+logger = logging.getLogger(__name__)
+
+
+class HealthMonitor:
+ """
+ 健康检查管理器
+
+ 职责:
+ 1. 监听 ServiceConnected 事件,启动定期健康检查
+ 2. 定期检查服务健康状态
+ 3. 发布 HealthCheckCompleted 事件
+ 4. 检测服务超时
+ """
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ registry: 'CoreRegistry',
+ lifecycle_config: 'ServiceLifecycleConfig',
+ global_agent_store_id: str = "global_agent_store",
+ ):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._config = lifecycle_config
+ self._global_agent_store_id = global_agent_store_id
+
+ # 从统一生命周期配置中读取参数
+ self._check_interval = lifecycle_config.normal_heartbeat_interval
+ self._warning_interval = lifecycle_config.warning_heartbeat_interval
+ self._timeout_threshold = lifecycle_config.initialization_timeout
+ self._ping_timeout = lifecycle_config.health_check_ping_timeout
+ self._warning_ping_timeout = getattr(lifecycle_config, "warning_ping_timeout", self._ping_timeout * 3)
+ self._transport_ping_timeout = {
+ "http": getattr(lifecycle_config, "ping_timeout_http", self._ping_timeout),
+ "sse": getattr(lifecycle_config, "ping_timeout_sse", self._ping_timeout),
+ "stdio": getattr(lifecycle_config, "ping_timeout_stdio", self._ping_timeout * 4),
+ }
+ # 新的按需检查节流控制
+ self._last_check_time: Dict[Tuple[str, str], float] = {}
+
+ # 健康检查任务跟踪
+ self._health_check_tasks: Dict[Tuple[str, str], asyncio.Task] = {} # (agent_id, service_name) -> task
+ self._is_running = False
+
+ # 订阅事件
+ self._event_bus.subscribe(ServiceConnected, self._on_service_connected, priority=30)
+ self._event_bus.subscribe(HealthCheckRequested, self._on_health_check_requested, priority=100)
+ self._event_bus.subscribe(ServiceStateChanged, self._on_state_changed, priority=20)
+
+ logger.info(
+ f"HealthMonitor initialized (bus={hex(id(self._event_bus))}, "
+ f"normal_interval={self._check_interval}s, warning_interval={self._warning_interval}s, "
+ f"timeout={self._timeout_threshold}s, ping_timeout={self._ping_timeout}s)"
+ )
+
+ async def start(self):
+ """启动健康监控"""
+ if self._is_running:
+ logger.warning("HealthMonitor is already running")
+ return
+
+ self._is_running = True
+ logger.info("HealthMonitor started")
+
+ async def stop(self):
+ """停止健康监控"""
+ self._is_running = False
+
+ # 取消所有健康检查任务
+ for task in self._health_check_tasks.values():
+ if not task.done():
+ task.cancel()
+
+ # 等待所有任务完成
+ if self._health_check_tasks:
+ await asyncio.gather(*self._health_check_tasks.values(), return_exceptions=True)
+
+ self._health_check_tasks.clear()
+ logger.info("HealthMonitor stopped")
+
+ async def _on_service_connected(self, event: ServiceConnected):
+ """
+ 处理服务连接成功 - 仅调度一次即时健康检查(不启动循环)
+ """
+ logger.info(f"[HEALTH] Starting health check for: {event.service_name}")
+
+ await self.maybe_schedule_health_check(event.agent_id, event.service_name, force=True)
+
+ async def _on_health_check_requested(self, event: HealthCheckRequested):
+ """
+ 处理健康检查请求 - 立即执行健康检查
+ """
+ # 统一使用全局命名空间读取状态(使用异步版本)
+ global_name = await self._to_global_name_async(event.agent_id, event.service_name)
+ current_state = await self._registry.get_service_state_async(self._global_agent_store_id, global_name)
+ logger.info(f"[HEALTH] Manual health check requested: {event.service_name} (state={getattr(current_state,'value',str(current_state))}, bus={hex(id(self._event_bus))})")
+
+ # 执行一次健康检查(关键路径使用同步派发,确保状态及时收敛)
+ await self._execute_health_check(event.agent_id, event.service_name, wait=True)
+
+ async def _on_state_changed(self, event: ServiceStateChanged):
+ """
+ 处理状态变更 - 停止已断开服务的健康检查
+ """
+ # 如果服务进入终止/不可达状态,停止健康检查(使用统一小写枚举值)
+ terminal_states = ["disconnected", "disconnecting", "unreachable"]
+ if event.new_state in terminal_states:
+ task_key = (event.agent_id, event.service_name)
+ if task_key in self._health_check_tasks:
+ task = self._health_check_tasks[task_key]
+ if not task.done():
+ task.cancel()
+ del self._health_check_tasks[task_key]
+ logger.info(f"[HEALTH] Stopped health check for terminated service: {event.service_name}")
+
+ async def _execute_health_check(self, agent_id: str, service_name: str, wait: bool = False):
+ """
+ 执行单次健康检查
+ """
+ start_time = time.time()
+
+ try:
+ # 如果服务已不存在,跳过检查,且停止周期任务
+ global_name = await self._to_global_name_async(agent_id, service_name)
+ # 使用异步 API 检查服务是否存在,避免在异步上下文中调用同步 API
+ if not await self._registry.has_service_async(self._global_agent_store_id, global_name):
+ logger.info(f"[HEALTH] Skip check for removed service: {service_name}")
+ task_key = (agent_id, service_name)
+ if task_key in self._health_check_tasks:
+ task = self._health_check_tasks.pop(task_key)
+ if not task.done():
+ task.cancel()
+ return
+
+ # 获取服务配置(新架构:从服务实体获取)
+ # 从服务实体中获取服务配置,不再从 client_config 中获取
+ service_entity = await self._registry._cache_service_manager.get_service(global_name)
+ if service_entity is None:
+ raise RuntimeError(
+ f"Service entity does not exist, cannot execute health check: service_name={service_name}, "
+ f"agent_id={agent_id}, global_name={global_name}"
+ )
+
+ service_config = service_entity.config
+ if not service_config:
+ raise RuntimeError(
+ f"Service configuration is empty, cannot execute health check: service_name={service_name}, "
+ f"agent_id={agent_id}, global_name={global_name}"
+ )
+
+ logger.debug(f"[HEALTH] Found service config for {service_name}: {list(service_config.keys())}")
+
+ # 根据当前状态动态调整健康检查超时(warning/reconnecting 更宽松)
+ try:
+ current_state = await self._registry.get_service_state_async(self._global_agent_store_id, global_name)
+ except Exception:
+ current_state = None
+
+ # 推断传输类型超时
+ effective_timeout = self._infer_transport_timeout(service_config)
+ if current_state in (ServiceConnectionState.WARNING, ServiceConnectionState.RECONNECTING):
+ # 进入警告/重连后延长超时,避免重复误判
+ effective_timeout = max(self._warning_ping_timeout, effective_timeout)
+ else:
+ effective_timeout = max(self._ping_timeout, effective_timeout)
+
+ # 执行健康检查(使用临时 client + async with)
+ try:
+ # 设置超时并使用临时 client 进行健康检查
+ ping_start = time.time()
+ async with asyncio.timeout(effective_timeout):
+ async with temp_client_for_service(global_name, service_config, timeout=effective_timeout) as client:
+ await client.ping()
+ response_time = time.time() - start_time
+
+ # 成功:仅上报成功与响应时间,不直接建议状态
+ logger.debug(f"[HEALTH] Check passed: {service_name} ({response_time:.2f}s)")
+
+ # 发布健康检查成功事件(手动检查使用同步派发)
+ # 注意:事件应该使用原始服务名称(Agent 视角),而非全局名称(Store 视角)
+ await self._publish_health_check_success(
+ agent_id, service_name, response_time, wait=wait
+ )
+ except asyncio.TimeoutError:
+ response_time = time.time() - start_time
+ logger.warning(f"[HEALTH] Check timeout: {service_name}")
+ # 注意:事件应该使用原始服务名称(Agent 视角),而非全局名称(Store 视角)
+ await self._publish_health_check_failed(
+ agent_id, service_name, response_time, "Health check timeout", wait=wait
+ )
+ except Exception as e:
+ response_time = time.time() - start_time
+ error_message = str(e)
+ logger.error(f"[HEALTH] Check failed: {service_name} - {error_message}")
+ # 分类认证失败,并记录到元数据
+ failure_reason = None
+ try:
+ status_code = getattr(getattr(e, 'response', None), 'status_code', None)
+ if status_code in (401, 403):
+ failure_reason = 'auth_failed'
+ else:
+ lower_msg = error_message.lower()
+ if any(word in lower_msg for word in ['unauthorized', 'forbidden', 'invalid token', 'invalid api key']):
+ failure_reason = 'auth_failed'
+ except Exception:
+ pass
+ try:
+ metadata = await self._registry.get_service_metadata_async(self._global_agent_store_id, global_name)
+ if metadata:
+ metadata.failure_reason = failure_reason
+ metadata.error_message = error_message
+ await self._registry.set_service_metadata_async(self._global_agent_store_id, global_name, metadata)
+ except Exception as e:
+ logger.error(f"[HEALTH] Failed to update metadata for {global_name}: {e}")
+ raise
+ # 注意:事件应该使用原始服务名称(Agent 视角),而非全局名称(Store 视角)
+ await self._publish_health_check_failed(
+ agent_id, service_name, response_time, error_message, wait=wait
+ )
+ except Exception as e:
+ logger.error(f"[HEALTH] Execute health check error: {service_name} - {e}", exc_info=True)
+
+ async def _publish_health_check_success(
+ self,
+ agent_id: str,
+ service_name: str,
+ response_time: float,
+ wait: bool = False
+ ):
+ """发布健康检查成功事件"""
+ event = HealthCheckCompleted(
+ agent_id=agent_id,
+ service_name=service_name,
+ success=True,
+ response_time=response_time,
+ suggested_state=None
+ )
+ await self._event_bus.publish(event, wait=wait)
+
+ async def _publish_health_check_failed(
+ self,
+ agent_id: str,
+ service_name: str,
+ response_time: float,
+ error_message: str,
+ wait: bool = False
+ ):
+ """发布健康检查失败事件"""
+ event = HealthCheckCompleted(
+ agent_id=agent_id,
+ service_name=service_name,
+ success=False,
+ response_time=response_time,
+ error_message=error_message,
+ suggested_state=None
+ )
+ await self._event_bus.publish(event, wait=wait)
+
+ async def _to_global_name_async(self, agent_id: str, service_name: str) -> str:
+ """将本地服务名映射为全局服务名(异步版本,映射失败则返回原名)。"""
+ try:
+ mapping = await self._registry.get_global_name_from_agent_service_async(agent_id, service_name)
+ return mapping or service_name
+ except Exception:
+ return service_name
+
+ def _infer_transport_timeout(self, service_config: dict) -> float:
+ """根据服务配置推断传输类型并返回对应超时。"""
+ transport = str(service_config.get("transport", "")).lower()
+ if not transport and service_config.get("url"):
+ # 默认 HTTP/Streamable
+ transport = "http"
+ if not transport and (service_config.get("command") or service_config.get("args")):
+ transport = "stdio"
+
+ if "sse" in transport:
+ return self._transport_ping_timeout.get("sse", self._ping_timeout)
+ if "stdio" in transport:
+ return self._transport_ping_timeout.get("stdio", self._ping_timeout)
+ return self._transport_ping_timeout.get("http", self._ping_timeout)
+
+ async def maybe_schedule_health_check(
+ self,
+ agent_id: str,
+ service_name: str,
+ current_state: ServiceConnectionState | str | None = None,
+ force: bool = False,
+ ) -> bool:
+ """
+ 按需调度健康检查:
+ - 读取状态或配置时调用,返回缓存状态后异步检查
+ - 按状态节流,避免高频调度
+ - force=True 时忽略节流,直接调度
+ """
+ if not self._is_running:
+ return False
+
+ key = (agent_id, service_name)
+ # 同一服务已有进行中的检查则不重复调度
+ existing = self._health_check_tasks.get(key)
+ if existing and not existing.done() and not force:
+ return False
+
+ try:
+ global_name = await self._to_global_name_async(agent_id, service_name)
+ except Exception:
+ global_name = service_name
+
+ # 获取状态用于节流
+ state = current_state
+ if isinstance(state, str):
+ try:
+ state = ServiceConnectionState(state)
+ except ValueError:
+ state = None
+ if state is None:
+ try:
+ state = await self._registry.get_service_state_async(self._global_agent_store_id, global_name)
+ except Exception:
+ state = None
+
+ now = time.time()
+ last = self._last_check_time.get(key, 0)
+ interval = self._warning_interval if state in (ServiceConnectionState.WARNING, ServiceConnectionState.RECONNECTING) else self._check_interval
+
+ if not force and (now - last) < interval:
+ return False
+
+ self._last_check_time[key] = now
+ task = asyncio.create_task(self._execute_health_check(agent_id, service_name))
+ self._health_check_tasks[key] = task
+
+ def _cleanup(_):
+ self._health_check_tasks.pop(key, None)
+
+ task.add_done_callback(_cleanup)
+ return True
+
+ async def check_timeouts(self):
+ """
+ 检查超时的服务(可由外部定期调用)
+ """
+ current_time = time.time()
+
+ try:
+ # 从缓存层获取所有服务并检查超时
+ # 使用 _cache_layer_manager(CacheLayerManager),它有 get_all_entities_async 方法
+ service_entities = await self._registry._cache_layer_manager.get_all_entities_async("services")
+
+ for entity_key, entity_data in service_entities.items():
+ if hasattr(entity_data, 'value'):
+ data = entity_data.value
+ elif isinstance(entity_data, dict):
+ data = entity_data
+ else:
+ continue
+
+ agent_id = data.get('source_agent', 'unknown')
+ service_name = data.get('service_original_name', entity_key)
+
+ # 从缓存层获取服务元数据
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+ if not metadata:
+ continue
+
+ # 检查初始化超时
+ state = await self._registry.get_service_state_async(agent_id, service_name)
+ if state == ServiceConnectionState.INITIALIZING:
+ state_entered_time = await self._get_state_entered_time(metadata)
+ if state_entered_time:
+ elapsed = current_time - state_entered_time.timestamp()
+ if elapsed > self._timeout_threshold:
+ logger.warning(f"[HEALTH] Initialization timeout: {service_name} ({elapsed:.1f}s)")
+ await self._publish_timeout_event(
+ agent_id, service_name, "initialization", elapsed
+ )
+
+ except Exception as e:
+ logger.error(f"[HEALTH] Health check timeout failed: {e}", exc_info=True)
+
+ async def _publish_timeout_event(
+ self,
+ agent_id: str,
+ service_name: str,
+ timeout_type: str,
+ elapsed_time: float
+ ):
+ """发布超时事件"""
+ event = ServiceTimeout(
+ agent_id=agent_id,
+ service_name=service_name,
+ timeout_type=timeout_type,
+ elapsed_time=elapsed_time
+ )
+ await self._event_bus.publish(event)
+
+ async def _get_state_entered_time(self, metadata):
+ """
+ 从元数据中获取状态进入时间
+
+ Args:
+ metadata: 服务元数据
+
+ Returns:
+ 状态进入时间的datetime对象,如果不存在则返回None
+ """
+ if hasattr(metadata, 'state_entered_time'):
+ return metadata.state_entered_time
+ elif isinstance(metadata, dict):
+ state_entered_time = metadata.get('state_entered_time')
+ if state_entered_time:
+ if isinstance(state_entered_time, str):
+ # 尝试解析ISO格式时间字符串
+ try:
+ from datetime import datetime
+ return datetime.fromisoformat(state_entered_time.replace('Z', '+00:00'))
+ except:
+ return None
+ return state_entered_time
+ return None
diff --git a/src/mcpstore/core/domain/lifecycle_manager.py b/src/mcpstore/core/domain/lifecycle_manager.py
new file mode 100644
index 00000000..cf1542a2
--- /dev/null
+++ b/src/mcpstore/core/domain/lifecycle_manager.py
@@ -0,0 +1,1047 @@
+"""
+Lifecycle Manager - Responsible for service state management
+
+Responsibilities:
+1. Listen to ServiceCached events, initialize lifecycle state
+2. Listen to ServiceConnected/ServiceConnectionFailed events, transition states
+3. Publish ServiceStateChanged events
+4. Manage state metadata
+"""
+
+import asyncio
+import logging
+from datetime import datetime
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceCached,
+ ServiceInitialized,
+ ServiceConnected,
+ ServiceConnectionFailed,
+ ServiceStateChanged,
+ ServiceBootstrapped,
+ ServiceBootstrapFailed,
+)
+from mcpstore.core.models.service import ServiceConnectionState, ServiceStateMetadata
+
+logger = logging.getLogger(__name__)
+
+
+class LifecycleManager:
+ """
+ Lifecycle Manager
+
+ Responsibilities:
+ 1. Listen to ServiceCached events, initialize lifecycle state
+ 2. Listen to ServiceConnected/ServiceConnectionFailed events, transition states
+ 3. Publish ServiceStateChanged events
+ 4. Manage state metadata
+
+ 重要设计原则:
+ - 使用 AgentLocks 保证与 CacheManager 的操作顺序一致
+ - 只更新健康状态,不触碰工具状态(工具状态由 CacheManager 管理)
+ """
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ registry: 'CoreRegistry',
+ lifecycle_config: 'ServiceLifecycleConfig' = None,
+ agent_locks: 'AgentLocks' = None
+ ):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._agent_locks = agent_locks
+
+ # Configuration (thresholds/heartbeat intervals)
+ if lifecycle_config is None:
+ # 从 MCPStoreConfig 获取配置(有默认回退)
+ from mcpstore.config.toml_config import get_lifecycle_config_with_defaults
+ lifecycle_config = get_lifecycle_config_with_defaults()
+ logger.info(f"LifecycleManager using config from {'MCPStoreConfig' if hasattr(lifecycle_config, 'warning_failure_threshold') else 'defaults'}")
+ self._config = lifecycle_config
+
+ # Subscribe to events
+ self._event_bus.subscribe(ServiceCached, self._on_service_cached, priority=90)
+ self._event_bus.subscribe(ServiceConnected, self._on_service_connected, priority=40)
+ self._event_bus.subscribe(ServiceConnectionFailed, self._on_service_connection_failed, priority=40)
+ self._event_bus.subscribe(ServiceBootstrapped, self._on_service_bootstrapped, priority=70)
+ self._event_bus.subscribe(ServiceBootstrapFailed, self._on_service_bootstrap_failed, priority=20)
+
+ # [NEW] Subscribe to health check and timeout events
+ from mcpstore.core.events.service_events import HealthCheckCompleted, ServiceTimeout, ReconnectionRequested
+ self._event_bus.subscribe(HealthCheckCompleted, self._on_health_check_completed, priority=50)
+ self._event_bus.subscribe(ServiceTimeout, self._on_service_timeout, priority=50)
+ self._event_bus.subscribe(ReconnectionRequested, self._on_reconnection_requested, priority=30)
+
+ logger.info("LifecycleManager initialized and subscribed to events")
+ # 健康成功但无工具时的重连尝试上限,避免无工具服务产生无限循环
+ self._max_tool_resync_attempts = 2
+ # setup/bootstrap 链路的连接并发限制,避免启动时风暴
+ self._bootstrap_semaphore = asyncio.Semaphore(5)
+
+ async def _set_service_metadata_async(self, agent_id: str, service_name: str, metadata) -> None:
+ """
+ 异步元数据设置:直接使用缓存层的异步API
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. Imperative Shell: 直接使用异步API,避免同步/异步混用
+ 2. 通过正确的异步渠道进行元数据管理
+ 3. 避免复杂的线程池转换
+ """
+ try:
+ # 直接使用缓存层的异步API设置元数据
+ from mcpstore.core.cache.naming_service import NamingService
+ global_name = NamingService.generate_service_global_name(service_name, agent_id)
+
+ # 转换元数据为字典
+ # ServiceStateMetadata 是 Pydantic BaseModel,使用 model_dump() 或 dict() 方法
+ if hasattr(metadata, 'model_dump'):
+ # Pydantic v2
+ metadata_dict = metadata.model_dump(mode='json')
+ elif hasattr(metadata, 'dict'):
+ # Pydantic v1
+ metadata_dict = metadata.dict()
+ elif isinstance(metadata, dict):
+ metadata_dict = metadata
+ else:
+ raise TypeError(
+ f"metadata must be a dict or Pydantic BaseModel, got: {type(metadata).__name__}"
+ )
+
+ # 通过 CacheLayerManager 异步保存元数据
+ # 注意:必须使用 _cache_layer_manager,而不是 _cache_layer
+ # _cache_layer 在 Redis 模式下是 RedisStore,没有 put_state 方法
+ await self._registry._cache_layer_manager.put_state("service_metadata", global_name, metadata_dict)
+
+ logger.debug(f"[LIFECYCLE] Service metadata set successfully: {global_name}")
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to set service metadata for {agent_id}:{service_name}: {e}")
+ raise RuntimeError(
+ f"Failed to set service metadata: agent_id={agent_id}, service_name={service_name}, error={e}"
+ ) from e
+
+ async def _set_service_state_async(self, agent_id: str, service_name: str, state) -> None:
+ """
+ 异步状态设置:只更新健康状态,不触碰工具状态
+
+ 重要设计原则(方案 C):
+ - LifecycleManager 只负责管理健康状态
+ - 工具状态由 CacheManager 独占管理
+ - 避免竞态条件导致工具状态被覆盖
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. Imperative Shell: 直接使用异步API,避免同步/异步混用
+ 2. 通过正确的异步渠道进行状态管理
+ 3. 避免复杂的线程池转换
+ """
+ try:
+ # 直接使用 StateManager 的异步API
+ from mcpstore.core.cache.naming_service import NamingService
+ global_name = NamingService.generate_service_global_name(service_name, agent_id)
+
+ # 转换 ServiceConnectionState 为字符串
+ if hasattr(state, 'value'):
+ health_status = state.value
+ else:
+ health_status = str(state)
+
+ # 使用缓存层状态管理器(cache/state_manager.py)
+ cache_state_manager = getattr(self._registry, '_cache_state_manager', None)
+ if cache_state_manager is None:
+ raise RuntimeError(
+ "Cache layer StateManager not initialized. "
+ "Please ensure ServiceRegistry correctly initializes the _cache_state_manager attribute."
+ )
+
+ # 获取现有的服务状态
+ existing_status = await cache_state_manager.get_service_status(global_name)
+
+ if existing_status:
+ # 关键修复(方案 C):保留现有的工具状态,只更新健康状态
+ # 工具状态由 CacheManager._update_service_status 独占管理
+ tools_status = [
+ {
+ "tool_global_name": tool.tool_global_name,
+ "tool_original_name": tool.tool_original_name,
+ "status": tool.status
+ }
+ for tool in existing_status.tools
+ ]
+
+ await cache_state_manager.update_service_status(
+ global_name,
+ health_status,
+ tools_status
+ )
+ logger.debug(
+ f"[LIFECYCLE] Updated health status: {global_name} -> {health_status}, "
+ f"preserved tools count: {len(tools_status)}"
+ )
+ else:
+ # 状态不存在时,不创建新状态
+ # 状态应该由 CacheManager 在处理 ServiceConnected 事件时创建
+ logger.warning(
+ f"[LIFECYCLE] Service state does not exist, skipping update: {global_name}. "
+ f"State will be created by CacheManager."
+ )
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to set service state for {agent_id}:{service_name}: {e}")
+ raise
+
+ async def _on_service_cached(self, event: ServiceCached):
+ """
+ Handle service cached event - initialize lifecycle state
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. 纯异步操作,避免任何同步/异步混用
+ 2. 通过正确的异步API访问状态,而不是直接访问内部服务
+ 3. 确保事件发布和健康检查触发的可靠性
+ """
+ logger.info(f"[LIFECYCLE] Initializing lifecycle for: {event.service_name}")
+
+ try:
+ # 1. 纯异步检查现有元数据(遵循核心原则)
+ existing_metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+
+ service_config = None
+ if existing_metadata and existing_metadata.service_config:
+ # 保留现有配置信息
+ service_config = existing_metadata.service_config
+ logger.debug(f"[LIFECYCLE] Preserving existing service_config for: {event.service_name}")
+ else:
+ # 优先从服务实体获取配置,避免依赖外部 mcp.json
+ try:
+ service_info = await self._registry.get_complete_service_info_async(event.agent_id, event.service_name)
+ if service_info and service_info.get("config"):
+ service_config = service_info["config"]
+ logger.debug(f"[LIFECYCLE] Loaded service_config from service entity for: {event.service_name}")
+ except Exception as entity_error:
+ # 按要求:不兼容旧架构,直接抛出错误
+ raise RuntimeError(f"Unable to get service configuration from service entity: {event.service_name}") from entity_error
+
+ # 2. 创建元数据(纯函数操作)
+ metadata = ServiceStateMetadata(
+ service_name=event.service_name,
+ agent_id=event.agent_id,
+ state_entered_time=datetime.now(),
+ consecutive_failures=0,
+ reconnect_attempts=0,
+ next_retry_time=None,
+ error_message=None,
+ service_config=service_config
+ )
+
+ # 3. 通过正确的异步API保存元数据
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+
+ # 验证保存成功
+ logger.debug(f"[LIFECYCLE] Metadata saved with config keys: {list(service_config.keys()) if service_config else 'None'}")
+ logger.info(f"[LIFECYCLE] Lifecycle initialized: {event.service_name} -> INITIALIZING")
+
+ # 4. 发布初始化完成事件(同步等待确保完成)
+ initialized_event = ServiceInitialized(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ initial_state="initializing"
+ )
+ await self._event_bus.publish(initialized_event, wait=True)
+ logger.debug(f"[LIFECYCLE] ServiceInitialized event published for: {event.service_name}")
+
+ # 5. 触发初始健康检查(关键修复:确保事件被正确发布)
+ logger.info(f"[LIFECYCLE] Triggering initial health check for {event.service_name}")
+ try:
+ from mcpstore.core.events.service_events import HealthCheckRequested
+ health_check_event = HealthCheckRequested(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ check_type="initial"
+ )
+ await self._event_bus.publish(health_check_event, wait=False)
+ logger.info(f"[LIFECYCLE] HealthCheckRequested event published for: {event.service_name}")
+ except Exception as health_event_error:
+ logger.error(f"[LIFECYCLE] Failed to publish HealthCheckRequested for {event.service_name}: {health_event_error}", exc_info=True)
+ # 不抛出异常,允许生命周期初始化继续
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to initialize lifecycle for {event.service_name}: {e}", exc_info=True)
+ # 发布失败事件以便其他组件处理
+ try:
+ from mcpstore.core.events.service_events import ServiceOperationFailed
+ error_event = ServiceOperationFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ operation="lifecycle_initialization",
+ error_message=str(e),
+ original_event=event
+ )
+ await self._event_bus.publish(error_event, wait=False)
+ except Exception as publish_error:
+ logger.error(f"[LIFECYCLE] Failed to publish error event for {event.service_name}: {publish_error}")
+
+ async def _on_service_bootstrapped(self, event: ServiceBootstrapped):
+ """
+ 处理 setup/bootstrap 重放完成事件:写入元数据并后台触发连接/健康收敛
+ """
+ logger.info(f"[LIFECYCLE] [BOOTSTRAP] Initializing lifecycle for: {event.service_name}")
+
+ try:
+ service_config = event.service_config or {}
+ if not service_config:
+ try:
+ service_info = await self._registry.get_complete_service_info_async(event.agent_id, event.service_name)
+ if service_info and service_info.get("config"):
+ service_config = service_info["config"]
+ logger.debug(f"[LIFECYCLE] [BOOTSTRAP] Loaded service_config from entity for: {event.service_name}")
+ except Exception as entity_error:
+ logger.error(f"[LIFECYCLE] [BOOTSTRAP] Cannot load service_config: {entity_error}")
+ raise
+
+ metadata = ServiceStateMetadata(
+ service_name=event.service_name,
+ agent_id=event.agent_id,
+ state_entered_time=datetime.now(),
+ consecutive_failures=0,
+ reconnect_attempts=0,
+ next_retry_time=None,
+ error_message=None,
+ service_config=service_config
+ )
+
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ await self._set_service_state_async(event.agent_id, event.service_name, ServiceConnectionState.INITIALIZING)
+
+ async def _dispatch_connect_and_health():
+ async with self._bootstrap_semaphore:
+ try:
+ init_event = ServiceInitialized(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ initial_state="initializing"
+ )
+ await self._event_bus.publish(init_event, wait=True)
+ except Exception as connect_err:
+ failed_event = ServiceBootstrapFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ error_message=str(connect_err),
+ source=event.source,
+ original_event=event
+ )
+ try:
+ await self._event_bus.publish(failed_event, wait=False)
+ except Exception:
+ logger.error(f"[LIFECYCLE] [BOOTSTRAP] Failed to publish ServiceBootstrapFailed for {event.service_name}")
+
+ asyncio.create_task(_dispatch_connect_and_health())
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] [BOOTSTRAP] Failed to initialize lifecycle for {event.service_name}: {e}", exc_info=True)
+ try:
+ failed_event = ServiceBootstrapFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ error_message=str(e),
+ source=event.source,
+ original_event=event
+ )
+ await self._event_bus.publish(failed_event, wait=False)
+ except Exception as pub_err:
+ logger.error(f"[LIFECYCLE] [BOOTSTRAP] Failed to publish ServiceBootstrapFailed: {pub_err}")
+
+ async def _on_service_bootstrap_failed(self, event: ServiceBootstrapFailed):
+ """记录 bootstrap 失败,保持非阻塞"""
+ logger.error(f"[LIFECYCLE] [BOOTSTRAP] Service bootstrap failed: {event.service_name} error={event.error_message}")
+
+ async def _on_service_connected(self, event: ServiceConnected):
+ """
+ Handle successful service connection - transition state to HEALTHY
+
+ 重要设计原则(方案 A + C):
+ - 使用 AgentLocks 保证与 CacheManager 的操作顺序一致
+ - 只更新健康状态,不触碰工具状态
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. 纯异步操作,使用正确的异步API
+ 2. 状态转换和元数据更新分离
+ 3. 错误处理和事件发布
+ """
+ logger.info(f"[LIFECYCLE] Service connected: {event.service_name}")
+
+ try:
+ # 方案 A:使用 AgentLocks 保证与 CacheManager 的操作顺序一致
+ # CacheManager 先执行(priority=50),写入工具状态
+ # LifecycleManager 后执行(priority=40),只更新健康状态
+ if self._agent_locks:
+ async with self._agent_locks.write(
+ event.agent_id,
+ operation="lifecycle_on_service_connected"
+ ):
+ await self._handle_service_connected_internal(event)
+ else:
+ # 没有锁时直接执行(向后兼容,但会记录警告)
+ logger.warning(
+ f"[LIFECYCLE] AgentLocks not configured, potential race condition: {event.service_name}"
+ )
+ await self._handle_service_connected_internal(event)
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to transition state for {event.service_name}: {e}", exc_info=True)
+ # 发布错误事件
+ try:
+ from mcpstore.core.events.service_events import ServiceOperationFailed
+ error_event = ServiceOperationFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ operation="state_transition",
+ error_message=str(e),
+ original_event=event
+ )
+ await self._event_bus.publish(error_event, wait=False)
+ except Exception as publish_error:
+ logger.error(f"[LIFECYCLE] Failed to publish error event for {event.service_name}: {publish_error}")
+
+ async def _handle_service_connected_internal(self, event: ServiceConnected):
+ """
+ Handle service connection success internal logic (executed under lock protection)
+ """
+ # 1. 通过异步API转换状态到 HEALTHY
+ # 方案 C:只更新健康状态,不触碰工具状态
+ await self._set_service_state_async(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ state=ServiceConnectionState.HEALTHY
+ )
+ logger.debug(f"[LIFECYCLE] State transitioned to HEALTHY for: {event.service_name}")
+
+ # 2. 获取并更新元数据(异步操作)
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ if metadata:
+ # 更新失败计数和连接信息(纯函数操作)
+ metadata.consecutive_failures = 0
+ metadata.reconnect_attempts = 0
+ metadata.error_message = None
+ metadata.last_health_check = datetime.now()
+ metadata.last_response_time = event.connection_time
+ # 记录工具同步信息,连接成功后工具被写入时重置重试计数
+ tools_count = None
+ try:
+ global_name = self._registry._naming.generate_service_global_name(
+ event.service_name,
+ event.agent_id
+ )
+ tools = await self._registry._relation_manager.get_service_tools(global_name)
+ tools_count = len(tools)
+ except Exception as tool_err:
+ logger.debug(f"[LIFECYCLE] Skip tool sync metadata update for {event.service_name}: {tool_err}")
+
+ # 无论工具获取是否成功,连接成功后重置工具重试计数并清空空工具标记
+ metadata.tool_sync_attempts = 0
+ metadata.tools_confirmed_empty = False
+ if tools_count and tools_count > 0:
+ metadata.last_tool_sync = datetime.now()
+
+ # 保存更新后的元数据(异步API)
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ logger.debug(f"[LIFECYCLE] Metadata updated for connected service: {event.service_name}")
+ else:
+ raise RuntimeError(
+ f"Service metadata does not exist, data inconsistency: "
+ f"service_name={event.service_name}, agent_id={event.agent_id}. "
+ f"Metadata should be created when handling ServiceCached event."
+ )
+
+ # 3. 发布状态转换事件
+ try:
+ from mcpstore.core.events.service_events import ServiceStateChanged
+ state_changed_event = ServiceStateChanged(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ old_state="initializing",
+ new_state="healthy",
+ reason="connection_successful"
+ )
+ await self._event_bus.publish(state_changed_event, wait=False)
+ logger.debug(f"[LIFECYCLE] ServiceStateChanged event published for: {event.service_name}")
+ except Exception as event_error:
+ logger.error(f"[LIFECYCLE] Failed to publish state change event for {event.service_name}: {event_error}")
+
+ async def _on_service_connection_failed(self, event: ServiceConnectionFailed):
+ """
+ Handle service connection failure - update metadata but let health check manage state
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. 纯异步操作,使用正确的异步API
+ 2. 只更新元数据,不直接转换状态
+ 3. 让 HealthMonitor 通过健康检查处理状态转换
+ """
+ logger.warning(f"[LIFECYCLE] Service connection failed: {event.service_name} ({event.error_message})")
+
+ try:
+ # 1. 通过异步API获取现有元数据
+ metadata = None
+ try:
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ except Exception as metadata_error:
+ logger.warning(f"[LIFECYCLE] Failed to get metadata for {event.service_name}: {metadata_error}")
+ metadata = None
+
+ # 2. 更新失败信息(纯函数操作)
+ if metadata:
+ metadata.consecutive_failures += 1
+ metadata.error_message = event.error_message
+ metadata.last_failure_time = datetime.now()
+ # 使用已有字段记录重连计数,避免写入不存在的属性
+ metadata.reconnect_attempts = event.retry_count
+
+ # 保存更新后的元数据(异步API)
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ logger.info(f"[LIFECYCLE] Updated failure metadata for {event.service_name}: {metadata.consecutive_failures} failures, retry_count={event.retry_count}")
+ else:
+ logger.warning(f"[LIFECYCLE] No metadata found for {event.service_name}, skipping failure update")
+
+ # 3. 明确记录:不立即转换状态,让 HealthMonitor 处理;仅在初始连接失败或达到阈值时进入 RECONNECTING
+ logger.info(f"[LIFECYCLE] Connection failure handled, deferring state transition to health monitor")
+
+ try:
+ current_state = await self._registry.get_service_state_async(event.agent_id, event.service_name)
+ except Exception:
+ current_state = None
+
+ # 仅在初次连接或明确达到重连阈值时切换,避免单次抖动直接进入重连
+ should_enter_reconnecting = False
+ if current_state in (ServiceConnectionState.INITIALIZING, None):
+ should_enter_reconnecting = True
+ elif metadata and metadata.consecutive_failures >= self._config.reconnecting_failure_threshold:
+ should_enter_reconnecting = current_state not in (
+ ServiceConnectionState.RECONNECTING,
+ ServiceConnectionState.UNREACHABLE,
+ ServiceConnectionState.DISCONNECTED,
+ )
+
+ if should_enter_reconnecting:
+ await self._transition_state(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ new_state=ServiceConnectionState.RECONNECTING,
+ reason="connection_failed",
+ source="LifecycleManager"
+ )
+
+ # 4. 发布连接失败事件(可能触发其他组件的处理)
+ try:
+ # 这个事件可以用于通知外部监控系统
+ from mcpstore.core.events.service_events import ServiceOperationFailed
+ failure_event = ServiceOperationFailed(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ operation="connection",
+ error_message=event.error_message,
+ original_event=event
+ )
+ await self._event_bus.publish(failure_event, wait=False)
+ logger.debug(f"[LIFECYCLE] ServiceOperationFailed event published for connection failure: {event.service_name}")
+ except Exception as publish_error:
+ logger.error(f"[LIFECYCLE] Failed to publish failure event for {event.service_name}: {publish_error}")
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to handle connection failure for {event.service_name}: {e}", exc_info=True)
+
+ async def _on_health_check_completed(self, event: 'HealthCheckCompleted'):
+ """
+ Handle health check completion - transition service state based on health status
+
+ 严格按照 Functional Core, Imperative Shell 原则:
+ 1. 纯异步操作,使用正确的异步API
+ 2. 状态转换逻辑清晰分离
+ 3. 遵循阈值配置进行状态管理
+ """
+ logger.debug(f"[LIFECYCLE] Health check completed: {event.service_name} (success={event.success})")
+
+ try:
+ # 1. 通过异步API获取现有元数据
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ if metadata:
+ # 更新健康检查信息(纯函数操作)
+ metadata.last_health_check = datetime.now()
+ metadata.last_response_time = event.response_time
+
+ if event.success:
+ metadata.consecutive_failures = 0
+ metadata.error_message = None
+ else:
+ metadata.consecutive_failures += 1
+ metadata.error_message = event.error_message
+
+ # 保存更新后的元数据(异步API)
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ logger.debug(f"[LIFECYCLE] Updated health check metadata for: {event.service_name}")
+
+ # 2. 通过异步API获取当前状态
+ current_state = await self._registry.get_service_state_async(event.agent_id, event.service_name)
+ failures = 0
+ if metadata:
+ failures = metadata.consecutive_failures
+
+ # Success: return from INITIALIZING/WARNING to HEALTHY; HEALTHY stays
+ if event.success:
+ # 健康成功但工具为空时触发受控的重连以拉取工具,避免“健康但无工具”
+ await self._maybe_trigger_tool_resync(event.agent_id, event.service_name)
+ if current_state in (ServiceConnectionState.INITIALIZING, ServiceConnectionState.WARNING):
+ await self._transition_state(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ new_state=ServiceConnectionState.HEALTHY,
+ reason="health_check_success",
+ source="HealthMonitor"
+ )
+ return
+
+ # Failure: advance to WARNING/RECONNECTING based on thresholds
+ warn_th = self._config.warning_failure_threshold
+ rec_th = self._config.reconnecting_failure_threshold
+
+ # Reached reconnection threshold: enter RECONNECTING
+ if failures >= rec_th:
+ if current_state != ServiceConnectionState.RECONNECTING:
+ await self._transition_state(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ new_state=ServiceConnectionState.RECONNECTING,
+ reason="health_check_consecutive_failures",
+ source="HealthMonitor"
+ )
+ return
+
+ # Enter WARNING from HEALTHY (first failure)
+ if current_state == ServiceConnectionState.HEALTHY and failures >= warn_th:
+ await self._transition_state(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ new_state=ServiceConnectionState.WARNING,
+ reason="health_check_first_failure",
+ source="HealthMonitor"
+ )
+ return
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to handle health check result for {event.service_name}: {e}", exc_info=True)
+
+ async def _on_service_timeout(self, event: 'ServiceTimeout'):
+ """
+ Handle service timeout - transition state to UNREACHABLE
+ """
+ logger.warning(
+ f"[LIFECYCLE] Service timeout: {event.service_name} "
+ f"(type={event.timeout_type}, elapsed={event.elapsed_time:.1f}s)"
+ )
+
+ try:
+ # Update metadata through async API
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ if metadata:
+ metadata.error_message = f"Timeout: {event.timeout_type} ({event.elapsed_time:.1f}s)"
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+
+ # Transition to UNREACHABLE state
+ await self._transition_state(
+ agent_id=event.agent_id,
+ service_name=event.service_name,
+ new_state=ServiceConnectionState.UNREACHABLE,
+ reason=f"timeout_{event.timeout_type}",
+ source="HealthMonitor"
+ )
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to handle timeout for {event.service_name}: {e}", exc_info=True)
+
+ async def _on_reconnection_requested(self, event: 'ReconnectionRequested'):
+ """
+ Handle reconnection request - log event (actual reconnection handled by ConnectionManager)
+ """
+ logger.info(
+ f"[LIFECYCLE] Reconnection requested: {event.service_name} "
+ f"(retry={event.retry_count}, reason={event.reason})"
+ )
+
+ # Update reconnection attempt count in metadata
+ try:
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ if metadata:
+ metadata.reconnect_attempts = event.retry_count
+ await self._set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to update reconnection metadata: {e}")
+
+ async def _maybe_trigger_tool_resync(self, agent_id: str, service_name: str) -> None:
+ """
+ 健康检查成功后如果工具列表为空,受控触发一次重连以补齐工具。
+ - 使用工具关系是否存在来判断
+ - 增加尝试上限,避免无工具服务产生无限循环
+ """
+ try:
+ global_name = self._registry._naming.generate_service_global_name(service_name, agent_id)
+ # 正在初始化/重连时不再触发额外重连,避免并发重复连接
+ current_state = await self._registry.get_service_state_async(agent_id, service_name)
+ if current_state in (
+ ServiceConnectionState.INITIALIZING,
+ ServiceConnectionState.RECONNECTING,
+ ):
+ logger.debug(f"[LIFECYCLE] Skip tool resync while state={getattr(current_state, 'value', current_state)} for {service_name}")
+ return
+
+ tools = await self._registry._relation_manager.get_service_tools(global_name)
+ tools_count = len(tools)
+
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+ if metadata is None:
+ logger.warning(f"[LIFECYCLE] Missing metadata for {service_name}, skip tool resync to avoid loop")
+ return
+
+ # 有工具:重置计数并退出
+ if tools_count > 0:
+ metadata.tool_sync_attempts = 0
+ metadata.tools_confirmed_empty = False
+ metadata.last_tool_sync = datetime.now()
+ await self._set_service_metadata_async(agent_id, service_name, metadata)
+ return
+
+ # 工具为空:判断是否需要重连
+ attempts = metadata.tool_sync_attempts
+ if metadata.tools_confirmed_empty:
+ logger.debug(f"[LIFECYCLE] Tools already confirmed empty for {service_name}, skip resync")
+ return
+
+ if attempts >= self._max_tool_resync_attempts:
+ metadata.tools_confirmed_empty = True
+ await self._set_service_metadata_async(agent_id, service_name, metadata)
+ logger.info(
+ f"[LIFECYCLE] Skip tool resync for {service_name}, attempts={attempts} reach limit"
+ )
+ return
+
+ # 触发重连拉取工具
+ metadata.tool_sync_attempts = attempts + 1
+ await self._set_service_metadata_async(agent_id, service_name, metadata)
+
+ from mcpstore.core.events.service_events import ReconnectionRequested
+ recon_event = ReconnectionRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ retry_count=0,
+ reason="health_success_missing_tools"
+ )
+ await self._event_bus.publish(recon_event, wait=False)
+ logger.info(
+ f"[LIFECYCLE] Trigger tool resync via reconnection: {service_name}, attempts={attempts + 1}"
+ )
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Tool resync decision failed for {service_name}: {e}", exc_info=True)
+
+ async def initialize_service(self, agent_id: str, service_name: str, service_config: dict) -> bool:
+ """
+ 初始化服务(异步版)
+
+ - 在事件循环内直接 await,不再通过同步包装器绕行
+ - 生成/复用 client_id 后发布 ServiceAddRequested 事件
+ """
+ try:
+ logger.info(f"[LIFECYCLE] initialize_service called: agent={agent_id}, service={service_name}")
+ logger.debug(f"[LIFECYCLE] Service config: {service_config}")
+
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ global_agent_store_id=agent_id
+ )
+ logger.debug(f"[LIFECYCLE] Generated client_id: {client_id}")
+
+ # 复用已存在映射(避免重复写入)
+ try:
+ existing_client_id = await self._registry._agent_client_service.get_service_client_id_async(agent_id, service_name)
+ if existing_client_id:
+ logger.debug(f"[LIFECYCLE] Found existing client_id mapping: {existing_client_id}")
+ client_id = existing_client_id
+ except Exception as map_err:
+ logger.warning(f"[LIFECYCLE] Failed to fetch existing client_id mapping: {map_err}")
+
+ from mcpstore.core.events.service_events import ServiceAddRequested
+
+ event = ServiceAddRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ client_id=client_id,
+ source="lifecycle_manager",
+ wait_timeout=0
+ )
+
+ logger.info(f"[LIFECYCLE] Publishing ServiceAddRequested event for {service_name}")
+ await self._event_bus.publish(event, wait=True)
+ logger.info(f"[LIFECYCLE] Service {service_name} initialization triggered successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to initialize service {service_name}: {e}", exc_info=True)
+ return False
+
+ def initialize_service_sync(self, agent_id: str, service_name: str, service_config: dict) -> bool:
+ """
+ 同步包装器:在无事件循环的场景使用,内部通过 bridge 执行异步逻辑。
+ """
+ try:
+ from mcpstore.core.bridge import get_async_bridge
+ bridge = get_async_bridge()
+ return bridge.run(
+ self.initialize_service(agent_id, service_name, service_config),
+ op_name="LifecycleManager.initialize_service"
+ )
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to initialize service (sync wrapper) {service_name}: {e}", exc_info=True)
+ return False
+
+ async def graceful_disconnect(self, agent_id: str, service_name: str, reason: str = "user_requested"):
+ """Gracefully disconnect service (does not modify config/registry entities, only lifecycle disconnection).
+
+ - Set state to DISCONNECTING → DISCONNECTED
+ - Record disconnect reason in metadata
+ - Upper layer (optional) cleans up tool display cache
+ """
+ try:
+ # Update disconnect reason
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+ if metadata:
+ try:
+ metadata.disconnect_reason = reason
+ await self._set_service_metadata_async(agent_id, service_name, metadata)
+ except Exception:
+ pass
+
+ # First enter DISCONNECTING
+ await self._transition_state(
+ agent_id=agent_id,
+ service_name=service_name,
+ new_state=ServiceConnectionState.DISCONNECTING,
+ reason=reason,
+ source="LifecycleManager"
+ )
+
+ # Immediately converge to DISCONNECTED (don't wait for external callback)
+ await self._transition_state(
+ agent_id=agent_id,
+ service_name=service_name,
+ new_state=ServiceConnectionState.DISCONNECTED,
+ reason=reason,
+ source="LifecycleManager"
+ )
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] graceful_disconnect failed for {service_name}: {e}", exc_info=True)
+
+ async def _transition_state(
+ self,
+ agent_id: str,
+ service_name: str,
+ new_state: ServiceConnectionState,
+ reason: str,
+ source: str
+ ):
+ """
+ Execute state transition (single entry point)
+ """
+ # Get current state (async interface)
+ try:
+ old_state = await self._registry.get_service_state_async(agent_id, service_name)
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to get current state for {service_name}: {e}")
+ old_state = None
+
+ if old_state == new_state:
+ logger.debug(f"[LIFECYCLE] State unchanged: {service_name} already in {new_state.value}")
+ return
+
+ logger.info(
+ f"[LIFECYCLE] State transition: {service_name} "
+ f"{old_state.value if old_state else 'None'} -> {new_state.value} "
+ f"(reason={reason}, source={source})"
+ )
+
+ # Update state (async interface)
+ await self._registry.set_service_state_async(agent_id, service_name, new_state)
+
+ # Update metadata (async get from pykv)
+ try:
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+
+ if metadata:
+ if hasattr(metadata, 'state_entered_time'):
+ metadata.state_entered_time = datetime.now()
+ try:
+ await self._registry.set_service_metadata_async(agent_id, service_name, metadata)
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to update metadata for {service_name}: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Error updating metadata for {service_name}: {e}")
+ raise
+
+ # Publish state change event
+ state_changed_event = ServiceStateChanged(
+ agent_id=agent_id,
+ service_name=service_name,
+ old_state=old_state.value if old_state else "none",
+ new_state=new_state.value,
+ reason=reason,
+ source=source
+ )
+ await self._event_bus.publish(state_changed_event)
+
+ async def handle_health_check_result(
+ self,
+ agent_id: str,
+ service_name: str,
+ success: bool,
+ response_time: float,
+ error_message: str = None
+ ) -> None:
+ """
+ Handle health check result from service connection attempt.
+
+ This method is called by orchestrator when a service connection attempt
+ completes, allowing the LifecycleManager to transition service state
+ based on the connection result.
+
+ Args:
+ agent_id: Agent ID that owns the service
+ service_name: Service name
+ success: Whether the connection/health check succeeded
+ response_time: Response time of the health check
+ error_message: Error message if the check failed
+ """
+ logger.info(
+ f"[LIFECYCLE] Handle health check result: {service_name} "
+ f"(success={success}, response_time={response_time:.3f}s, error={error_message})"
+ )
+
+ try:
+ logger.info(f"[LIFECYCLE] Starting state transition logic for {service_name}")
+ # Update metadata
+ try:
+ logger.info(f"[LIFECYCLE] Registry type: {type(self._registry)}")
+ logger.info(f"[LIFECYCLE] Found get_service_metadata method: {hasattr(self._registry, 'get_service_metadata')}")
+
+ # 使用统一的异步API
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+
+ logger.info(f"[LIFECYCLE] Retrieved metadata for {service_name}: {metadata is not None}")
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to get metadata for {service_name}: {e}")
+ metadata = None
+
+ # 简化元数据处理
+ try:
+ if metadata:
+ # 更新现有元数据
+ logger.info(f"[LIFECYCLE] Updating existing metadata for {service_name}")
+ if hasattr(metadata, 'last_health_check'):
+ metadata.last_health_check = datetime.now()
+ if hasattr(metadata, 'last_response_time'):
+ metadata.last_response_time = response_time
+
+ if success:
+ if hasattr(metadata, 'consecutive_failures'):
+ metadata.consecutive_failures = 0
+ if hasattr(metadata, 'error_message'):
+ metadata.error_message = None
+ else:
+ if hasattr(metadata, 'consecutive_failures'):
+ metadata.consecutive_failures = getattr(metadata, 'consecutive_failures', 0) + 1
+ if hasattr(metadata, 'error_message'):
+ metadata.error_message = error_message
+
+ try:
+ self._registry.set_service_metadata(agent_id, service_name, metadata)
+ logger.info(f"[LIFECYCLE] Updated metadata for {service_name}")
+ except Exception as e:
+ logger.warning(f"[LIFECYCLE] Failed to update metadata for {service_name}: {e}")
+ else:
+ logger.info(f"[LIFECYCLE] No existing metadata found for {service_name}")
+ except Exception as e:
+ logger.warning(f"[LIFECYCLE] Error processing metadata for {service_name}: {e}")
+
+ # Get current state
+ try:
+ # 使用统一的异步API
+ current_state = await self._registry.get_service_state_async(agent_id, service_name)
+
+ logger.info(f"[LIFECYCLE] Current state for {service_name}: {current_state}")
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to get current state for {service_name}: {e}")
+ current_state = None
+
+ # Transition state based on result
+ if success:
+ # Success: transition to HEALTHY from any state
+ if current_state != ServiceConnectionState.HEALTHY:
+ await self._transition_state(
+ agent_id=agent_id,
+ service_name=service_name,
+ new_state=ServiceConnectionState.HEALTHY,
+ reason="connection_success",
+ source="Orchestrator"
+ )
+ logger.info(f"[LIFECYCLE] Service {service_name} transitioned to HEALTHY (connection success)")
+ else:
+ logger.debug(f"[LIFECYCLE] Service {service_name} already HEALTHY")
+ else:
+ # Failure: if no current state, assume INITIALIZING and transition to RECONNECTING
+ if current_state is None:
+ logger.info(f"[LIFECYCLE] Assuming current state is INITIALIZING for {service_name}")
+ current_state = ServiceConnectionState.INITIALIZING
+ # Failure: determine target state based on current state and failure count
+ failure_count = metadata.consecutive_failures if metadata else 1
+
+ if current_state == ServiceConnectionState.INITIALIZING:
+ # First connection failure -> RECONNECTING
+ new_state = ServiceConnectionState.RECONNECTING
+ reason = "initial_connection_failed"
+ logger.info(f"[LIFECYCLE] Will transition {service_name} from INITIALIZING to RECONNECTING (first failure)")
+ elif failure_count >= self._config.reconnecting_failure_threshold:
+ # High failure count -> UNREACHABLE
+ new_state = ServiceConnectionState.UNREACHABLE
+ reason = "connection_unreachable"
+ elif failure_count >= self._config.warning_failure_threshold:
+ # Medium failure count -> WARNING
+ new_state = ServiceConnectionState.WARNING
+ reason = "connection_warning"
+ else:
+ # Low failure count -> RECONNECTING
+ new_state = ServiceConnectionState.RECONNECTING
+ reason = "connection_failed"
+
+ if current_state != new_state:
+ try:
+ logger.info(f"[LIFECYCLE] About to call _transition_state for {service_name}: {current_state.value} -> {new_state.value}")
+ await self._transition_state(
+ agent_id=agent_id,
+ service_name=service_name,
+ new_state=new_state,
+ reason=reason,
+ source="Orchestrator"
+ )
+ logger.info(
+ f"[LIFECYCLE] Service {service_name} transitioned to {new_state.value} "
+ f"(reason={reason}, failures={failure_count})"
+ )
+ except Exception as e:
+ logger.error(f"[LIFECYCLE] Failed to transition {service_name} to {new_state.value}: {e}")
+ else:
+ logger.debug(f"[LIFECYCLE] Service {service_name} already in {new_state.value}")
+
+ except Exception as e:
+ logger.error(
+ f"[LIFECYCLE] Failed to handle health check result for {service_name}: {e}",
+ exc_info=True
+ )
diff --git a/src/mcpstore/core/domain/persistence_manager.py b/src/mcpstore/core/domain/persistence_manager.py
new file mode 100644
index 00000000..b5af8afe
--- /dev/null
+++ b/src/mcpstore/core/domain/persistence_manager.py
@@ -0,0 +1,89 @@
+"""
+持久化管理器 - 负责文件持久化
+
+职责:
+1. 监听 ServiceAddRequested 事件
+2. 异步持久化到文件(不阻塞)
+3. 发布 ServicePersisted 事件
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any, TYPE_CHECKING
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import ServiceAddRequested, ServicePersisted
+
+if TYPE_CHECKING:
+ from mcpstore.core.configuration.unified_config import UnifiedConfigManager
+
+logger = logging.getLogger(__name__)
+
+
+class PersistenceManager:
+ """
+ 持久化管理器
+
+ 职责:
+ 1. 监听 ServiceAddRequested 事件
+ 2. 异步持久化到文件(不阻塞)
+ 3. 发布 ServicePersisted 事件
+ """
+
+ def __init__(self, event_bus: EventBus, config_manager: 'UnifiedConfigManager'):
+ self._event_bus = event_bus
+ self._config_manager = config_manager
+ self._persistence_lock = asyncio.Lock()
+
+ # 订阅事件(低优先级,不阻塞主流程)
+ self._event_bus.subscribe(ServiceAddRequested, self._on_service_add_requested, priority=10)
+
+ logger.info("PersistenceManager initialized and subscribed to events")
+
+ async def _on_service_add_requested(self, event: ServiceAddRequested):
+ """
+ 处理服务添加请求 - 异步持久化
+ """
+ logger.info(f"[PERSISTENCE] Persisting service: {event.service_name}")
+ target_name = event.global_name or event.service_name
+
+ try:
+ async with self._persistence_lock:
+ # 持久化到 mcp.json
+ await self._persist_to_mcp_json(target_name, event.service_config)
+
+ logger.info(f"[PERSISTENCE] Service persisted: {target_name}")
+
+ # 发布持久化完成事件
+ persisted_event = ServicePersisted(
+ agent_id=event.agent_id,
+ service_name=target_name,
+ file_path="mcp.json"
+ )
+ await self._event_bus.publish(persisted_event)
+
+ except Exception as e:
+ logger.error(f"[PERSISTENCE] Failed to persist {event.service_name}: {e}", exc_info=True)
+ # 持久化失败不影响主流程,只记录日志
+
+ async def _persist_to_mcp_json(self, service_name: str, service_config: Dict[str, Any]):
+ """持久化到 mcp.json"""
+ # 🆕 修复:UnifiedConfigManager 的 load_config/save_config 方法在 mcp_config 对象上
+ # 读取当前配置
+ current_config = self._config_manager.mcp_config.load_config()
+
+ # 使用全局名(若事件携带)
+ from mcpstore.core.events.service_events import ServiceAddRequested # type hint
+ target_name = service_name
+
+ # 更新配置
+ if "mcpServers" not in current_config:
+ current_config["mcpServers"] = {}
+
+ current_config["mcpServers"][target_name] = service_config
+
+ # 保存配置
+ success = self._config_manager.mcp_config.save_config(current_config)
+
+ if not success:
+ raise RuntimeError("Failed to save config to mcp.json")
diff --git a/src/mcpstore/core/domain/reconnection_scheduler.py b/src/mcpstore/core/domain/reconnection_scheduler.py
new file mode 100644
index 00000000..66ec2890
--- /dev/null
+++ b/src/mcpstore/core/domain/reconnection_scheduler.py
@@ -0,0 +1,494 @@
+"""
+重连调度器 - 负责自动重连管理
+
+职责:
+1. 定期扫描 RECONNECTING 状态的服务
+2. 检查是否到达重连时间
+3. 发布 ReconnectionRequested 事件
+4. 管理重连延迟策略(指数退避)
+"""
+
+import asyncio
+import logging
+import time
+from datetime import datetime, timedelta
+from typing import Dict, Optional, List
+
+from mcpstore.core.events.event_bus import EventBus
+from mcpstore.core.events.service_events import (
+ ServiceStateChanged, ReconnectionRequested, ReconnectionScheduled,
+ ServiceConnectionFailed
+)
+from mcpstore.core.lifecycle.config import ServiceLifecycleConfig
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+
+class ReconnectionScheduler:
+ """
+ 重连调度器
+
+ 职责:
+ 1. 定期扫描 RECONNECTING 状态的服务
+ 2. 检查是否到达重连时间
+ 3. 发布 ReconnectionRequested 事件
+ 4. 管理重连延迟策略(指数退避)
+ """
+
+ def __init__(
+ self,
+ event_bus: EventBus,
+ registry: 'CoreRegistry',
+ lifecycle_config: 'ServiceLifecycleConfig',
+ scan_interval: float = 1.0, # 默认1秒扫描一次
+ ):
+ self._event_bus = event_bus
+ self._registry = registry
+ self._config = lifecycle_config
+ self._scan_interval = scan_interval
+ # 从统一配置读取重连相关参数
+ self._base_delay = lifecycle_config.base_reconnect_delay
+ self._max_delay = lifecycle_config.max_reconnect_delay
+ self._max_retries = lifecycle_config.max_reconnect_attempts
+ self._empty_scan_log_interval = 30.0
+ self._last_empty_scan_log = 0.0
+
+ # 调度器状态
+ self._is_running = False
+ self._scheduler_task: Optional[asyncio.Task] = None
+
+ # 订阅事件
+ self._event_bus.subscribe(ServiceStateChanged, self._on_state_changed, priority=20)
+ self._event_bus.subscribe(ServiceConnectionFailed, self._on_connection_failed, priority=50)
+
+ logger.info(f"ReconnectionScheduler initialized (scan_interval={scan_interval}s)")
+
+ async def start(self):
+ """启动重连调度器"""
+ if self._is_running:
+ logger.warning("ReconnectionScheduler is already running")
+ return
+
+ self._is_running = True
+
+ # 启动调度循环
+ self._scheduler_task = asyncio.create_task(self._scheduler_loop())
+
+ logger.info("ReconnectionScheduler started")
+
+ async def stop(self):
+ """停止重连调度器"""
+ self._is_running = False
+
+ # 取消调度任务
+ if self._scheduler_task and not self._scheduler_task.done():
+ self._scheduler_task.cancel()
+ try:
+ await self._scheduler_task
+ except asyncio.CancelledError:
+ pass
+
+ logger.info("ReconnectionScheduler stopped")
+
+ async def _scheduler_loop(self):
+ """
+ 调度循环 - 定期扫描需要重连的服务
+ """
+ logger.debug("[RECONNECT] Scheduler loop started")
+
+ try:
+ while self._is_running:
+ # 扫描需要重连的服务
+ await self._scan_reconnection_services()
+
+ # 等待下一个扫描周期
+ await asyncio.sleep(self._scan_interval)
+
+ except asyncio.CancelledError:
+ logger.debug("[RECONNECT] Scheduler loop cancelled")
+ except Exception as e:
+ logger.error(f"[RECONNECT] Scheduler loop error: {e}", exc_info=True)
+
+ async def _scan_reconnection_services(self):
+ """
+ 扫描所有 RECONNECTING 状态的服务
+
+ 严格按照Functional Core, Imperative Shell原则:
+ 1. 调用纯同步核心生成扫描计划
+ 2. 纯异步执行缓存访问和事件发布
+ 3. 避免直接访问内存字典
+ """
+ try:
+ # 1. 调用纯同步核心生成扫描计划
+ scan_plan = self._generate_scan_plan()
+ self._log_scan_summary(len(scan_plan["services_to_check"]))
+
+ # 2. 纯异步执行扫描操作
+ await self._execute_scan_plan(scan_plan)
+
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to scan services: {e}", exc_info=True)
+
+ def _log_scan_summary(self, count: int) -> None:
+ """控制扫描结果日志的频率,避免空计划时频繁刷屏"""
+ if count > 0:
+ logger.debug(f"[RECONNECT] [SCAN] Scan plan generated: {count} services need to be checked")
+ return
+ now = time.time()
+ if now - self._last_empty_scan_log >= self._empty_scan_log_interval:
+ logger.debug("[RECONNECT] [SCAN] Scan plan generated: 0 services need to be checked")
+ self._last_empty_scan_log = now
+
+ def _generate_scan_plan(self) -> Dict[str, any]:
+ """
+ 纯同步核心:生成重连扫描计划
+
+ 不涉及任何IO操作,只生成操作计划
+
+ Returns:
+ 包含需要检查的服务列表的字典
+ """
+ current_time = datetime.now()
+ services_to_check = []
+
+ # 通过事件系统获取所有服务,避免直接访问内存字典
+ # 这里暂时使用简化的实现,后续可以通过事件获取服务列表
+
+ return {
+ "scan_time": current_time,
+ "services_to_check": services_to_check,
+ "max_retries": self._max_retries
+ }
+
+ async def _execute_scan_plan(self, scan_plan: Dict[str, any]):
+ """
+ 异步外壳:执行扫描计划
+
+ Args:
+ scan_plan: 由_generate_scan_plan生成的扫描计划
+ """
+ current_time = scan_plan["scan_time"]
+ services_to_check = scan_plan["services_to_check"]
+
+ if not services_to_check:
+ # 没有需要检查的服务,从缓存层获取所有服务并检查状态
+ services_to_check = await self._get_all_services_from_cache()
+
+ for service_info in services_to_check:
+ agent_id = service_info["agent_id"]
+ service_name = service_info["service_name"]
+
+ try:
+ # 从缓存层获取服务状态
+ state = await self._get_service_state_from_cache(agent_id, service_name)
+
+ # 只处理 RECONNECTING 状态的服务
+ if state != ServiceConnectionState.RECONNECTING:
+ continue
+
+ # 从缓存层获取服务元数据
+ metadata = await self._get_service_metadata_from_cache(agent_id, service_name)
+ if not metadata:
+ continue
+
+ # 检查是否到达重连时间
+ if await self._should_retry_connection(metadata, current_time):
+ retry_count = await self._get_retry_count(metadata)
+
+ # 检查是否超过最大重试次数
+ if retry_count >= self._max_retries:
+ logger.warning(
+ f"[RECONNECT] Max retries reached: {service_name} (retries={retry_count})"
+ )
+ # 转换到 UNREACHABLE 状态
+ await self._transition_to_unreachable(agent_id, service_name)
+ continue
+
+ # 发布重连请求事件
+ logger.info(
+ f"[RECONNECT] Triggering reconnection: {service_name} "
+ f"(retry={retry_count + 1}/{self._max_retries})"
+ )
+
+ await self._publish_reconnection_requested(
+ agent_id, service_name, retry_count
+ )
+
+ # 更新元数据中的重试计数
+ metadata.reconnect_attempts = retry_count + 1
+ await self._set_service_metadata_in_cache(agent_id, service_name, metadata)
+
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to process service {service_name}: {e}", exc_info=True)
+
+ # ==================== 缓存层访问辅助方法 ====================
+
+ async def _get_all_services_from_cache(self) -> List[Dict[str, str]]:
+ """
+ 从缓存层获取所有服务
+
+ Returns:
+ 服务信息列表,每个元素包含 agent_id 和 service_name
+ """
+ try:
+ # 从缓存层获取所有服务实体
+ services = []
+
+ # 使用 _cache_layer_manager(CacheLayerManager)获取所有服务实体
+ # 不再使用 _cache_layer,因为它在 Redis 模式下是 RedisStore,没有 get_all_entities_async 方法
+ service_entities = await self._registry._cache_layer_manager.get_all_entities_async("services")
+
+ for entity_key, entity_data in service_entities.items():
+ if hasattr(entity_data, 'value'):
+ data = entity_data.value
+ elif isinstance(entity_data, dict):
+ data = entity_data
+ else:
+ continue
+
+ agent_id = data.get('source_agent', 'unknown')
+ service_name = data.get('service_original_name', entity_key)
+
+ services.append({
+ "agent_id": agent_id,
+ "service_name": service_name
+ })
+
+ return services
+
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to get service list from cache layer: {e}")
+ return []
+
+ async def _get_service_state_from_cache(self, agent_id: str, service_name: str) -> ServiceConnectionState:
+ """
+ 从缓存层获取服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务连接状态
+ """
+ try:
+ state_data = await self._registry.get_service_state_async(agent_id, service_name)
+
+ if hasattr(state_data, 'health_status'):
+ return ServiceConnectionState(state_data.health_status)
+ elif isinstance(state_data, dict):
+ health_status = state_data.get("health_status", "disconnected")
+ return ServiceConnectionState(health_status)
+ else:
+ return ServiceConnectionState.DISCONNECTED
+
+ except Exception as e:
+ logger.debug(f"[RECONNECT] [ERROR] Failed to get service state {agent_id}:{service_name}: {e}")
+ return ServiceConnectionState.DISCONNECTED
+
+ async def _get_service_metadata_from_cache(self, agent_id: str, service_name: str):
+ """
+ 从缓存层获取服务元数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务元数据,如果不存在则返回None
+ """
+ try:
+ return await self._registry.get_service_metadata_async(agent_id, service_name)
+ except Exception as e:
+ logger.debug(f"[RECONNECT] [ERROR] Failed to get service metadata {agent_id}:{service_name}: {e}")
+ return None
+
+ async def _set_service_metadata_in_cache(self, agent_id: str, service_name: str, metadata):
+ """
+ 在缓存层设置服务元数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务元数据
+ """
+ try:
+ # 使用原始架构签名的方法
+ await self._registry.set_service_metadata_async_v2(agent_id, service_name, metadata)
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to set service metadata {agent_id}:{service_name}: {e}")
+
+ async def _should_retry_connection(self, metadata, current_time) -> bool:
+ """
+ 判断是否应该重连
+
+ Args:
+ metadata: 服务元数据
+ current_time: 当前时间
+
+ Returns:
+ 是否应该重连
+ """
+ if not hasattr(metadata, 'next_retry_time') or metadata.next_retry_time is None:
+ return False
+
+ return current_time >= metadata.next_retry_time
+
+ async def _get_retry_count(self, metadata) -> int:
+ """
+ 获取重试次数
+
+ Args:
+ metadata: 服务元数据
+
+ Returns:
+ 重试次数
+ """
+ if hasattr(metadata, 'reconnect_attempts'):
+ return metadata.reconnect_attempts
+ elif isinstance(metadata, dict):
+ return metadata.get('reconnect_attempts', 0)
+ else:
+ return 0
+
+ async def _transition_to_unreachable(self, agent_id: str, service_name: str):
+ """
+ 将服务转换到UNREACHABLE状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ try:
+ logger.warning(f"[RECONNECT] Service marked as unreachable: {service_name}")
+
+ # 发布状态变更事件
+ state_event = ServiceStateChanged(
+ agent_id=agent_id,
+ service_name=service_name,
+ old_state="RECONNECTING",
+ new_state="UNREACHABLE",
+ timestamp=datetime.now(),
+ reason="Max retries exceeded"
+ )
+ await self._event_bus.publish(state_event)
+
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to transition state {agent_id}:{service_name}: {e}")
+
+ async def _publish_reconnection_requested(self, agent_id: str, service_name: str, retry_count: int):
+ """
+ 发布重连请求事件
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ retry_count: 重试次数
+ """
+ try:
+ reconnection_event = ReconnectionRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ retry_count=retry_count,
+ max_retries=self._max_retries,
+ timestamp=datetime.now()
+ )
+ await self._event_bus.publish(reconnection_event)
+
+ except Exception as e:
+ logger.error(f"[RECONNECT] [ERROR] Failed to publish reconnection event {agent_id}:{service_name}: {e}")
+
+ async def _on_state_changed(self, event: ServiceStateChanged):
+ """
+ 处理状态变更 - 重置重试计数器
+ """
+ # 如果服务成功连接,重置重试计数器
+ if event.new_state == "HEALTHY":
+ # 从 pykv 异步获取元数据
+ metadata = await self._registry.get_service_metadata_async(event.agent_id, event.service_name)
+ if metadata:
+ metadata.reconnect_attempts = 0
+ metadata.next_retry_time = None
+ await self._registry.set_service_metadata_async(event.agent_id, event.service_name, metadata)
+ logger.info(f"[RECONNECT] Service recovered, resetting retry count: {event.service_name}")
+
+ # 如果服务进入 RECONNECTING 状态,调度重连
+ elif event.new_state == "RECONNECTING":
+ await self._schedule_reconnection(event.agent_id, event.service_name)
+
+ async def _on_connection_failed(self, event: ServiceConnectionFailed):
+ """
+ 处理连接失败 - 调度重连
+ """
+ logger.debug(f"[RECONNECT] Connection failed, scheduling reconnection: {event.service_name}")
+ await self._schedule_reconnection(event.agent_id, event.service_name)
+
+ async def _schedule_reconnection(self, agent_id: str, service_name: str):
+ """
+ 调度重连 - 计算下次重连时间
+ """
+ # 从 pykv 异步获取元数据
+ metadata = await self._registry.get_service_metadata_async(agent_id, service_name)
+ if not metadata:
+ return
+
+ retry_count = metadata.reconnect_attempts
+
+ # 计算重连延迟(指数退避)
+ delay = self._calculate_reconnect_delay(retry_count)
+ next_retry_time = datetime.now() + timedelta(seconds=delay)
+
+ # 更新元数据
+ metadata.next_retry_time = next_retry_time
+ await self._registry.set_service_metadata_async(agent_id, service_name, metadata)
+
+ logger.info(
+ f"[RECONNECT] Scheduled reconnection: {service_name} "
+ f"(delay={delay:.1f}s, retry={retry_count})"
+ )
+
+ # 发布重连已调度事件
+ event = ReconnectionScheduled(
+ agent_id=agent_id,
+ service_name=service_name,
+ next_retry_time=next_retry_time.timestamp(),
+ retry_delay=delay
+ )
+ await self._event_bus.publish(event)
+
+ def _calculate_reconnect_delay(self, retry_count: int) -> float:
+ """
+ 计算重连延迟(指数退避)
+
+ 公式: delay = min(base_delay * 2^retry_count, max_delay)
+ """
+ delay = self._base_delay * (2 ** retry_count)
+ return min(delay, self._max_delay)
+
+ async def _publish_reconnection_requested(
+ self,
+ agent_id: str,
+ service_name: str,
+ retry_count: int
+ ):
+ """发布重连请求事件"""
+ event = ReconnectionRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ retry_count=retry_count,
+ reason="scheduled_retry"
+ )
+ await self._event_bus.publish(event)
+
+ async def _transition_to_unreachable(self, agent_id: str, service_name: str):
+ """通过事件系统请求转换到 UNREACHABLE 状态"""
+ from mcpstore.core.events.service_events import ServiceTimeout
+
+ event = ServiceTimeout(
+ agent_id=agent_id,
+ service_name=service_name,
+ timeout_type="max_retries",
+ elapsed_time=0.0,
+ )
+ await self._event_bus.publish(event)
diff --git a/src/mcpstore/core/events/__init__.py b/src/mcpstore/core/events/__init__.py
new file mode 100644
index 00000000..f50ec95f
--- /dev/null
+++ b/src/mcpstore/core/events/__init__.py
@@ -0,0 +1,58 @@
+"""
+事件系统模块
+
+提供事件驱动架构的核心组件:
+- 领域事件定义
+- 事件总线
+"""
+
+from .event_bus import EventBus, EventSubscription
+from .service_events import (
+ DomainEvent,
+ EventPriority,
+ ServiceAddRequested,
+ ServiceBootstrapRequested,
+ ServiceBootstrapped,
+ ServiceBootstrapFailed,
+ ServiceCached,
+ ServiceInitialized,
+ ServiceConnectionRequested,
+ ServiceConnected,
+ ServiceConnectionFailed,
+ ServiceStateChanged,
+ ServicePersisted,
+ ServiceOperationFailed,
+ HealthCheckRequested,
+ HealthCheckCompleted,
+ ServiceTimeout,
+ ReconnectionRequested,
+ ReconnectionScheduled,
+)
+
+__all__ = [
+ # 基础类
+ "DomainEvent",
+ "EventPriority",
+ "EventBus",
+ "EventSubscription",
+
+ # 服务事件
+ "ServiceAddRequested",
+ "ServiceBootstrapRequested",
+ "ServiceBootstrapped",
+ "ServiceBootstrapFailed",
+ "ServiceCached",
+ "ServiceInitialized",
+ "ServiceConnectionRequested",
+ "ServiceConnected",
+ "ServiceConnectionFailed",
+ "ServiceStateChanged",
+ "ServicePersisted",
+ "ServiceOperationFailed",
+ # 健康与重连事件
+ "HealthCheckRequested",
+ "HealthCheckCompleted",
+ "ServiceTimeout",
+ "ReconnectionRequested",
+ "ReconnectionScheduled",
+]
diff --git a/src/mcpstore/core/events/event_bus.py b/src/mcpstore/core/events/event_bus.py
new file mode 100644
index 00000000..9b5d5513
--- /dev/null
+++ b/src/mcpstore/core/events/event_bus.py
@@ -0,0 +1,233 @@
+"""
+事件总线 - 异步事件分发系统
+
+特性:
+- 异步事件分发
+- 优先级处理
+- 事件过滤
+- 错误隔离(一个handler失败不影响其他)
+- 事件历史记录(可选)
+"""
+
+import asyncio
+import logging
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Callable, Dict, List, Type, Optional, Tuple
+
+from .service_events import (
+ DomainEvent,
+ ServiceOperationFailed,
+ ServiceInitialized,
+ ServiceConnectionRequested,
+ HealthCheckRequested,
+ ServiceAddRequested,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class EventSubscription:
+ """事件订阅信息"""
+ event_type: Type[DomainEvent]
+ handler: Callable
+ priority: int = 0
+ filter_func: Optional[Callable[[DomainEvent], bool]] = None
+
+
+class EventBus:
+ """
+ 事件总线 - 核心事件分发系统
+
+ 职责:
+ 1. 管理事件订阅
+ 2. 异步分发事件
+ 3. 错误隔离
+ 4. 事件历史记录(可选)
+ """
+
+ def __init__(self, enable_history: bool = False, history_size: int = 1000, handler_timeout: Optional[float] = None):
+ self._subscribers: Dict[Type[DomainEvent], List[EventSubscription]] = defaultdict(list)
+ self._enable_history = enable_history
+ self._history: List[Tuple[datetime, DomainEvent]] = []
+ self._history_size = history_size
+ self._lock = asyncio.Lock()
+ self._handler_timeout = handler_timeout
+
+ logger.info(f"EventBus initialized id={hex(id(self))}")
+ # 关键事件白名单:这些事件将被强制以同步方式派发(wait=True)
+ # ServiceAddRequested 必须同步执行,确保缓存操作完成后再继续
+ self._critical_sync_events = (
+ ServiceInitialized,
+ ServiceConnectionRequested,
+ HealthCheckRequested,
+ ServiceAddRequested,
+ )
+
+ def subscribe(
+ self,
+ event_type: Type[DomainEvent],
+ handler: Callable,
+ priority: int = 0,
+ filter_func: Optional[Callable[[DomainEvent], bool]] = None
+ ):
+ """
+ 订阅事件
+
+ Args:
+ event_type: 事件类型
+ handler: 处理函数(必须是 async 函数)
+ priority: 优先级(数字越大越先执行)
+ filter_func: 过滤函数(返回True才处理)
+ """
+ if not asyncio.iscoroutinefunction(handler):
+ raise ValueError(f"Handler {handler.__name__} must be async function")
+
+ subscription = EventSubscription(
+ event_type=event_type,
+ handler=handler,
+ priority=priority,
+ filter_func=filter_func
+ )
+
+ self._subscribers[event_type].append(subscription)
+
+ # 按优先级排序(降序)
+ self._subscribers[event_type].sort(key=lambda s: s.priority, reverse=True)
+
+ logger.debug(f"[BUS {hex(id(self))}] Subscribed {handler.__name__} to {event_type.__name__} (priority={priority})")
+
+ def unsubscribe(self, event_type: Type[DomainEvent], handler: Callable) -> bool:
+ """取消订阅指定 handler(精确移除)。"""
+ subs = self._subscribers.get(event_type, [])
+ before = len(subs)
+ self._subscribers[event_type] = [s for s in subs if s.handler is not handler]
+ removed = before != len(self._subscribers[event_type])
+ if removed:
+ logger.debug(f"[BUS {hex(id(self))}] Unsubscribed {getattr(handler,'__name__',repr(handler))} from {event_type.__name__}")
+ return removed
+
+ async def publish(self, event: DomainEvent, wait: bool = False):
+ """
+ 发布事件
+
+ Args:
+ event: 领域事件
+ wait: 是否等待所有handler执行完成
+ """
+ #
+ # NOTE:
+ # 在 Windows+asyncio
+ # loop
+ #
+ is_critical = isinstance(event, self._critical_sync_events)
+ if is_critical and not wait:
+ logger.debug(f"[BUS {hex(id(self))}] Critical event {event.__class__.__name__} forcing wait=True")
+ wait = True
+
+ # 使 ServiceCached 也同步执行,避免生命周期初始化被取消
+ from .service_events import ServiceCached
+ if isinstance(event, ServiceCached) and not wait:
+ logger.debug(f"[BUS {hex(id(self))}] ServiceCached forcing wait=True to ensure lifecycle init")
+ wait = True
+ logger.debug(f"[BUS {hex(id(self))}] Publishing event: {event.__class__.__name__} (id={event.event_id}) wait={wait}")
+
+ # 记录历史
+ if self._enable_history:
+ async with self._lock:
+ self._history.append((datetime.now(), event))
+ if len(self._history) > self._history_size:
+ self._history.pop(0)
+
+ # 获取订阅者
+ subscribers = self._subscribers.get(type(event), [])
+ # Diagnostics: subscriber details
+ try:
+ handler_names = [getattr(s.handler, "__name__", repr(s.handler)) for s in subscribers]
+ except Exception:
+ handler_names = [""]
+ logger.debug(f"[BUS {hex(id(self))}] {event.__class__.__name__} subs={len(subscribers)} handlers={handler_names}")
+
+
+ if not subscribers:
+ logger.debug(f"No subscribers for {event.__class__.__name__}")
+ return
+
+ if wait:
+ # 同步顺序执行,保证关键事件在当前上下文中完成处理
+ for subscription in subscribers:
+ if subscription.filter_func and not subscription.filter_func(event):
+ continue
+ await self._handle_event_safely(subscription.handler, event)
+ else:
+ # 异步后台执行(fire-and-forget)
+ for subscription in subscribers:
+ if subscription.filter_func and not subscription.filter_func(event):
+ continue
+ asyncio.create_task(self._handle_event_safely(subscription.handler, event))
+
+ async def _handle_event_safely(self, handler: Callable, event: DomainEvent):
+ """
+ 安全地处理事件(隔离错误)
+ """
+ try:
+ if self._handler_timeout and self._handler_timeout > 0:
+ await asyncio.wait_for(handler(event), timeout=self._handler_timeout)
+ else:
+ await handler(event)
+ logger.debug(f"Handler {handler.__name__} completed for {event.__class__.__name__}")
+ except asyncio.CancelledError as ce:
+ logger.warning(f"Handler {handler.__name__} cancelled for {event.__class__.__name__}: {ce}")
+ # do not re-raise to avoid noisy loop exceptions
+ except GeneratorExit as ge:
+ logger.warning(f"Handler {handler.__name__} generator-exit for {event.__class__.__name__}: {ge}")
+ # do not re-raise to avoid noisy loop exceptions
+ except Exception as e:
+ logger.error(
+ f"Handler {handler.__name__} failed for {event.__class__.__name__}: {e}",
+ exc_info=True
+ )
+ # 发布错误事件(避免递归)
+ if not isinstance(event, ServiceOperationFailed):
+ error_event = ServiceOperationFailed(
+ agent_id=getattr(event, 'agent_id', 'unknown'),
+ service_name=getattr(event, 'service_name', 'unknown'),
+ operation=f"handle_{event.__class__.__name__}",
+ error_message=str(e),
+ original_event=event
+ )
+ await self.publish(error_event, wait=False)
+
+ def get_history(self, event_type: Optional[Type[DomainEvent]] = None) -> List[DomainEvent]:
+ """获取事件历史"""
+ if not self._enable_history:
+ return []
+
+ if event_type:
+ return [e for _, e in self._history if isinstance(e, event_type)]
+ return [e for _, e in self._history]
+
+ def clear_history(self):
+ """清空事件历史"""
+ self._history.clear()
+
+ def get_subscriber_count(self, event_type: Type[DomainEvent]) -> int:
+ """获取某个事件类型的订阅者数量"""
+ return len(self._subscribers.get(event_type, []))
+
+ def unsubscribe_all(self, event_type: Optional[Type[DomainEvent]] = None):
+ """
+ 取消订阅
+
+ Args:
+ event_type: 事件类型,如果为None则取消所有订阅
+ """
+ if event_type:
+ if event_type in self._subscribers:
+ del self._subscribers[event_type]
+ logger.debug(f"Unsubscribed all handlers from {event_type.__name__}")
+ else:
+ self._subscribers.clear()
+ logger.debug("Unsubscribed all handlers from all events")
diff --git a/src/mcpstore/core/events/service_events.py b/src/mcpstore/core/events/service_events.py
new file mode 100644
index 00000000..15acbbe6
--- /dev/null
+++ b/src/mcpstore/core/events/service_events.py
@@ -0,0 +1,224 @@
+"""
+Service-related domain event definitions
+
+All events are immutable (frozen=True) to ensure event integrity.
+"""
+
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from typing import Dict, Any, Optional, List, Tuple
+
+
+class EventPriority(Enum):
+ """Event priority"""
+ LOW = 1
+ NORMAL = 2
+ HIGH = 3
+ CRITICAL = 4
+
+
+@dataclass(frozen=True)
+class DomainEvent:
+ """
+ Domain event base class
+
+ Note: Required parameters of all subclasses must be defined before base class default parameters
+ """
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ timestamp: datetime = field(default_factory=datetime.now)
+ priority: EventPriority = field(default=EventPriority.NORMAL)
+
+ def __post_init__(self):
+ """事件创建后的验证"""
+ pass
+
+
+@dataclass(frozen=True)
+class ServiceAddRequested(DomainEvent):
+ """服务添加请求事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ service_config: Dict[str, Any] = field(default_factory=dict)
+ client_id: str = ""
+ global_name: str = "" # 可选:全局服务名(Agent 服务携带)
+ origin_agent_id: Optional[str] = None # 可选:原始 Agent(若 agent_id 被改写)
+ origin_local_name: Optional[str] = None # 可选:原始本地名(若 service_name 被改写)
+ source: str = "user" # user, system
+ wait_timeout: float = 0.0
+
+ def __post_init__(self):
+ super().__post_init__()
+ if not self.service_name:
+ raise ValueError("service_name cannot be empty")
+ if not self.service_config:
+ raise ValueError("service_config cannot be empty")
+
+
+@dataclass(frozen=True)
+class ServiceBootstrapRequested(DomainEvent):
+ """
+ 服务启动重放请求事件(用于 setup/bootstrap 场景,非用户主动添加)
+ """
+ agent_id: str = ""
+ service_name: str = ""
+ service_config: Dict[str, Any] = field(default_factory=dict)
+ client_id: str = ""
+ global_name: str = ""
+ origin_agent_id: Optional[str] = None
+ origin_local_name: Optional[str] = None
+ source: str = "bootstrap" # bootstrap_mcpjson / sync_mcpjson 等
+
+ def __post_init__(self):
+ super().__post_init__()
+ if not self.service_name:
+ raise ValueError("service_name cannot be empty")
+ if not self.service_config:
+ raise ValueError("service_config cannot be empty")
+
+
+@dataclass(frozen=True)
+class ServiceBootstrapped(DomainEvent):
+ """服务已完成启动重放的缓存构建"""
+ agent_id: str = ""
+ service_name: str = ""
+ client_id: str = ""
+ global_name: str = ""
+ source: str = "bootstrap"
+ service_config: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class ServiceBootstrapFailed(DomainEvent):
+ """服务启动重放失败事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ error_message: str = ""
+ source: str = "bootstrap"
+ original_event: Optional[DomainEvent] = None
+
+
+@dataclass(frozen=True)
+class ServiceCached(DomainEvent):
+ """服务已缓存事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ client_id: str = ""
+ cache_keys: List[str] = field(default_factory=list) # 记录缓存的键,用于回滚
+
+
+@dataclass(frozen=True)
+class ServiceInitialized(DomainEvent):
+ """服务生命周期已初始化事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ initial_state: str = "INITIALIZING" # "initializing"
+
+
+@dataclass(frozen=True)
+class ServiceConnectionRequested(DomainEvent):
+ """服务连接请求事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ service_config: Dict[str, Any] = field(default_factory=dict)
+ timeout: float = 3.0
+
+
+@dataclass(frozen=True)
+class ServiceConnected(DomainEvent):
+ """服务连接成功事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ session: Any = None # MCP Client session
+ tools: List[Tuple[str, Dict[str, Any]]] = field(default_factory=list)
+ connection_time: float = 0.0
+
+
+@dataclass(frozen=True)
+class ServiceConnectionFailed(DomainEvent):
+ """服务连接失败事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ error_message: str = ""
+ error_type: str = "" # timeout, network, auth, etc.
+ retry_count: int = 0
+
+
+@dataclass(frozen=True)
+class ServiceStateChanged(DomainEvent):
+ """服务状态变化事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ old_state: str = ""
+ new_state: str = ""
+ reason: str = ""
+ source: str = "" # 触发状态变化的来源
+
+
+@dataclass(frozen=True)
+class ServicePersisted(DomainEvent):
+ """服务已持久化事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ file_path: str = ""
+
+
+@dataclass(frozen=True)
+class ServiceOperationFailed(DomainEvent):
+ """服务操作失败事件(用于错误处理)"""
+ agent_id: str = ""
+ service_name: str = ""
+ operation: str = "" # cache, connect, persist, etc.
+ error_message: str = ""
+ original_event: Optional[DomainEvent] = None
+
+
+# === 健康检查相关事件 ===
+
+@dataclass(frozen=True)
+class HealthCheckRequested(DomainEvent):
+ """健康检查请求事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ check_type: str = "periodic" # periodic, manual, triggered
+
+
+@dataclass(frozen=True)
+class HealthCheckCompleted(DomainEvent):
+ """健康检查完成事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ success: bool = False
+ response_time: float = 0.0
+ error_message: Optional[str] = None
+ suggested_state: Optional[str] = None # HEALTHY, WARNING, RECONNECTING, UNREACHABLE
+
+
+@dataclass(frozen=True)
+class ServiceTimeout(DomainEvent):
+ """服务超时事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ timeout_type: str = "" # initialization, health_check, disconnection
+ elapsed_time: float = 0.0
+
+
+# === 重连相关事件 ===
+
+@dataclass(frozen=True)
+class ReconnectionRequested(DomainEvent):
+ """重连请求事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ retry_count: int = 0
+ reason: str = "scheduled_retry"
+
+
+@dataclass(frozen=True)
+class ReconnectionScheduled(DomainEvent):
+ """重连已调度事件"""
+ agent_id: str = ""
+ service_name: str = ""
+ next_retry_time: float = 0.0 # timestamp
+ retry_delay: float = 0.0 # seconds
diff --git a/src/mcpstore/core/exceptions.py b/src/mcpstore/core/exceptions.py
index 1759a164..257ad80f 100644
--- a/src/mcpstore/core/exceptions.py
+++ b/src/mcpstore/core/exceptions.py
@@ -1,19 +1,628 @@
"""
-MCP Store 异常类定义
+MCPStore Unified Exception System
+Provides a comprehensive exception hierarchy for both SDK and API usage
"""
-class MCPStoreError(Exception):
- """MCP Store 基础异常类"""
- pass
+import logging
+import traceback
+import uuid
+from datetime import datetime, timezone
+from enum import Enum
+from typing import Optional, Dict, Any, Union
+
+logger = logging.getLogger(__name__)
+
+
+class ErrorSeverity(Enum):
+ """Error severity levels"""
+ INFO = "info"
+ WARNING = "warning"
+ ERROR = "error"
+ CRITICAL = "critical"
+
+
+class ErrorCode(Enum):
+ """Unified error codes with HTTP status mapping"""
+
+ # General errors (500)
+ INTERNAL_ERROR = "INTERNAL_ERROR"
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
+
+ # Service errors (404, 503)
+ SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
+ SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
+ SERVICE_CONNECTION_ERROR = "SERVICE_CONNECTION_ERROR"
+
+ # Tool errors (404, 500)
+ TOOL_NOT_FOUND = "TOOL_NOT_FOUND"
+ TOOL_EXECUTION_ERROR = "TOOL_EXECUTION_ERROR"
+
+ # Configuration errors (400)
+ CONFIG_INVALID = "CONFIG_INVALID"
+ CONFIG_NOT_FOUND = "CONFIG_NOT_FOUND"
+ INVALID_PARAMETER = "INVALID_PARAMETER"
+ INVALID_REQUEST = "INVALID_REQUEST"
+
+ # Agent errors (404)
+ AGENT_NOT_FOUND = "AGENT_NOT_FOUND"
+
+ # Authentication/Authorization errors (401, 403)
+ AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED"
+ AUTHORIZATION_FAILED = "AUTHORIZATION_FAILED"
+
+ # Rate limiting (429)
+ RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
+
+ def to_http_status(self) -> int:
+ """Map error code to HTTP status code"""
+ mapping = {
+ # 400 Bad Request
+ self.CONFIG_INVALID: 400,
+ self.INVALID_PARAMETER: 400,
+ self.INVALID_REQUEST: 400,
+
+ # 401 Unauthorized
+ self.AUTHENTICATION_REQUIRED: 401,
+
+ # 403 Forbidden
+ self.AUTHORIZATION_FAILED: 403,
+
+ # 404 Not Found
+ self.SERVICE_NOT_FOUND: 404,
+ self.TOOL_NOT_FOUND: 404,
+ self.AGENT_NOT_FOUND: 404,
+ self.CONFIG_NOT_FOUND: 404,
+
+ # 429 Too Many Requests
+ self.RATE_LIMIT_EXCEEDED: 429,
+
+ # 500 Internal Server Error
+ self.INTERNAL_ERROR: 500,
+ self.UNKNOWN_ERROR: 500,
+ self.TOOL_EXECUTION_ERROR: 500,
+
+ # 503 Service Unavailable
+ self.SERVICE_UNAVAILABLE: 503,
+ self.SERVICE_CONNECTION_ERROR: 503,
+ }
+ return mapping.get(self, 500)
+
+
+class MCPStoreException(Exception):
+ """Unified base exception for MCPStore
+
+ This exception class is used for both SDK and API contexts.
+ It provides structured error information including error codes,
+ severity levels, and detailed context.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ error_code: Union[ErrorCode, str] = ErrorCode.INTERNAL_ERROR,
+ severity: ErrorSeverity = ErrorSeverity.ERROR,
+ status_code: Optional[int] = None,
+ details: Optional[Dict[str, Any]] = None,
+ cause: Optional[Exception] = None,
+ field: Optional[str] = None,
+ ):
+ """Initialize MCPStore exception
+
+ Args:
+ message: Human-readable error message
+ error_code: Error code (ErrorCode enum or string)
+ severity: Error severity level
+ status_code: HTTP status code (auto-derived from error_code if not provided)
+ details: Additional error details
+ cause: Original exception that caused this error
+ field: Field name that caused the error (for validation errors)
+ """
+ self.message = message
+
+ # Handle ErrorCode enum
+ if isinstance(error_code, ErrorCode):
+ self.error_code = error_code.value
+ self.status_code = status_code or error_code.to_http_status()
+ else:
+ self.error_code = error_code
+ self.status_code = status_code or 500
+
+ self.severity = severity
+ self.field = field
+ self.details = details or {}
+ self.cause = cause
+ self.timestamp = datetime.now(timezone.utc)
+ self.error_id = str(uuid.uuid4())[:8]
+
+ # Capture stack trace if cause is provided
+ if cause:
+ self.stack_trace = "".join(traceback.format_exception(type(cause), cause, cause.__traceback__))
+ else:
+ self.stack_trace = None
+
+ super().__init__(self.message)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert exception to dictionary (for API responses)
+
+ Returns:
+ Dictionary representation of the exception
+ """
+ result = {
+ "error_id": self.error_id,
+ "error_code": self.error_code,
+ "message": self.message,
+ "severity": self.severity.value,
+ "timestamp": self.timestamp.isoformat(),
+ }
+
+ if self.field:
+ result["field"] = self.field
+
+ if self.details:
+ result["details"] = self.details
+
+ if self.stack_trace:
+ result["stack_trace"] = self.stack_trace
+
+ return result
+
+ def __str__(self) -> str:
+ """String representation"""
+ return f"[{self.error_code}] {self.message} (error_id: {self.error_id})"
+
+ def __repr__(self) -> str:
+ """Detailed representation"""
+ return (
+ f"MCPStoreException("
+ f"error_code={self.error_code}, "
+ f"message={self.message!r}, "
+ f"error_id={self.error_id})"
+ )
+
+
+# === Specific Exception Classes ===
+
+class ServiceNotFoundException(MCPStoreException):
+ """Service not found exception"""
+
+ def __init__(self, service_name: str, agent_id: Optional[str] = None, **kwargs):
+ details = {"service_name": service_name}
+ if agent_id:
+ details["agent_id"] = agent_id
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=f"Service '{service_name}' not found",
+ error_code=ErrorCode.SERVICE_NOT_FOUND,
+ field="service_name",
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ServiceConnectionError(MCPStoreException):
+ """Service connection error"""
+
+ def __init__(self, service_name: str, reason: Optional[str] = None, **kwargs):
+ message = f"Failed to connect to service '{service_name}'"
+ if reason:
+ message += f": {reason}"
+
+ details = {"service_name": service_name}
+ if reason:
+ details["reason"] = reason
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.SERVICE_CONNECTION_ERROR,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ServiceUnavailableError(MCPStoreException):
+ """Service unavailable error"""
+
+ def __init__(self, service_name: str, reason: Optional[str] = None, **kwargs):
+ message = f"Service '{service_name}' is unavailable"
+ if reason:
+ message += f": {reason}"
+
+ details = {"service_name": service_name}
+ if reason:
+ details["reason"] = reason
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.SERVICE_UNAVAILABLE,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ToolNotFoundException(MCPStoreException):
+ """Tool not found exception"""
+
+ def __init__(self, tool_name: str, service_name: Optional[str] = None, **kwargs):
+ message = f"Tool '{tool_name}' not found"
+ if service_name:
+ message += f" in service '{service_name}'"
+
+ details = {"tool_name": tool_name}
+ if service_name:
+ details["service_name"] = service_name
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.TOOL_NOT_FOUND,
+ field="tool_name",
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ToolExecutionError(MCPStoreException):
+ """Tool execution error"""
+
+ def __init__(self, tool_name: str, reason: Optional[str] = None, **kwargs):
+ message = f"Failed to execute tool '{tool_name}'"
+ if reason:
+ message += f": {reason}"
+
+ details = {"tool_name": tool_name}
+ if reason:
+ details["reason"] = reason
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.TOOL_EXECUTION_ERROR,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ConfigurationException(MCPStoreException):
+ """Configuration exception"""
+
+ def __init__(self, message: str, config_path: Optional[str] = None, **kwargs):
+ details = {}
+ if config_path:
+ details["config_path"] = config_path
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.CONFIG_INVALID,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ValidationException(MCPStoreException):
+ """Validation exception"""
+
+ def __init__(self, message: str, field: Optional[str] = None, **kwargs):
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.INVALID_PARAMETER,
+ field=field,
+ **kwargs
+ )
+
+
+class AgentNotFoundException(MCPStoreException):
+ """Agent not found exception"""
+
+ def __init__(self, agent_id: str, **kwargs):
+ super().__init__(
+ message=f"Agent '{agent_id}' not found",
+ error_code=ErrorCode.AGENT_NOT_FOUND,
+ field="agent_id",
+ details={"agent_id": agent_id, **kwargs.get("details", {})},
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+# === Cache-Related Exceptions (for py-key-value integration) ===
+
+class CacheOperationError(MCPStoreException):
+ """Cache operation error
+
+ This exception is raised when a cache operation fails.
+ It is typically used to wrap py-key-value KeyValueOperationError exceptions.
+
+ Validates: Requirements 6.4 (Exception and Error Handling)
+ """
+
+ def __init__(self, message: str, operation: Optional[str] = None, **kwargs):
+ details = {}
+ if operation:
+ details["operation"] = operation
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.INTERNAL_ERROR,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class CacheConnectionError(MCPStoreException):
+ """Cache connection error
+
+ This exception is raised when unable to connect to the cache backend.
+ It is typically used to wrap py-key-value StoreConnectionError exceptions.
+
+ Validates: Requirements 6.4 (Exception and Error Handling)
+ """
+
+ def __init__(self, message: str, backend_type: Optional[str] = None, **kwargs):
+ details = {}
+ if backend_type:
+ details["backend_type"] = backend_type
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.SERVICE_CONNECTION_ERROR,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class CacheValidationError(MCPStoreException):
+ """Cache validation error
+
+ This exception is raised when cache data validation fails.
+ It is typically used to wrap py-key-value validation-related exceptions
+ such as SerializationError, DeserializationError, or InvalidKeyError.
+
+ Validates: Requirements 6.4 (Exception and Error Handling)
+ """
+
+ def __init__(self, message: str, validation_type: Optional[str] = None, **kwargs):
+ details = {}
+ if validation_type:
+ details["validation_type"] = validation_type
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.INVALID_PARAMETER,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class SessionSerializationError(MCPStoreException):
+ """Session serialization error
+
+ This exception is raised when attempting to serialize a Session object
+ that contains non-serializable references (e.g., connection objects).
+
+ Session objects should always remain in memory and never be serialized
+ to py-key-value storage.
+
+ Validates: Requirements 3.2 (Session Object Serialization Issues)
+ """
+
+ def __init__(self, message: str, session_info: Optional[Dict[str, Any]] = None, **kwargs):
+ details = {}
+ if session_info:
+ details.update(session_info)
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.INVALID_PARAMETER,
+ severity=ErrorSeverity.ERROR,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+# === 工具集管理相关异常 ===
+
+class ToolSetError(MCPStoreException):
+ """工具集错误基类
+
+ 所有工具集管理相关的异常都继承自此类
+
+ Validates: Requirements 6.2 (工具调用拦截)
+ """
+
+ def __init__(self, message: str, **kwargs):
+ super().__init__(
+ message=message,
+ error_code=kwargs.pop("error_code", ErrorCode.INTERNAL_ERROR),
+ **kwargs
+ )
+
+
+class ToolNotAvailableError(ToolSetError):
+ """工具不可用错误
+
+ 当用户尝试调用已被移除的工具时抛出此异常
+
+ Validates: Requirements 6.2 (工具调用拦截)
+ """
+
+ def __init__(
+ self,
+ tool_name: str,
+ service_name: Optional[str] = None,
+ agent_id: Optional[str] = None,
+ **kwargs
+ ):
+ message = f"工具 '{tool_name}' 不可用"
+ if service_name:
+ message += f"(服务: {service_name})"
+ message += "。使用 add_tools() 方法启用该工具。"
+
+ details = {"tool_name": tool_name}
+ if service_name:
+ details["service_name"] = service_name
+ if agent_id:
+ details["agent_id"] = agent_id
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.TOOL_NOT_FOUND,
+ field="tool_name",
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class CrossAgentOperationError(ToolSetError):
+ """跨 Agent 操作错误
+
+ 当尝试使用属于其他 Agent 的 ServiceProxy 进行操作时抛出此异常
+
+ Validates: Requirements 6.9 (跨 Agent 操作防护)
+ """
+
+ def __init__(
+ self,
+ current_agent_id: str,
+ service_agent_id: str,
+ service_name: str,
+ operation: Optional[str] = None,
+ **kwargs
+ ):
+ message = f"不允许跨 Agent 操作:服务 '{service_name}' 属于 Agent '{service_agent_id}',"
+ message += f"但当前 Agent 为 '{current_agent_id}'"
+ if operation:
+ message += f"(操作: {operation})"
+
+ details = {
+ "current_agent_id": current_agent_id,
+ "service_agent_id": service_agent_id,
+ "service_name": service_name
+ }
+ if operation:
+ details["operation"] = operation
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.AUTHORIZATION_FAILED,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ServiceMappingError(ToolSetError):
+ """服务映射错误
+
+ 当服务名称映射不存在或无效时抛出此异常
+
+ Validates: Requirements 6.10 (服务映射验证)
+ """
+
+ def __init__(
+ self,
+ service_name: str,
+ agent_id: Optional[str] = None,
+ mapping_type: Optional[str] = None,
+ **kwargs
+ ):
+ message = f"服务映射错误:服务 '{service_name}' 的映射不存在或无效"
+ if agent_id:
+ message += f"(Agent: {agent_id})"
+
+ details = {"service_name": service_name}
+ if agent_id:
+ details["agent_id"] = agent_id
+ if mapping_type:
+ details["mapping_type"] = mapping_type
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.SERVICE_NOT_FOUND,
+ field="service_name",
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class DataSourceNotFoundError(ToolSetError):
+ """数据源不存在错误
+
+ 当工具集状态数据源不存在时抛出此异常
+
+ Validates: Requirements 6.10 (数据源归属验证)
+ """
+
+ def __init__(
+ self,
+ agent_id: str,
+ service_name: str,
+ data_type: Optional[str] = None,
+ **kwargs
+ ):
+ message = f"数据源不存在:Agent '{agent_id}' 的服务 '{service_name}'"
+ if data_type:
+ message += f"(数据类型: {data_type})"
+
+ details = {
+ "agent_id": agent_id,
+ "service_name": service_name
+ }
+ if data_type:
+ details["data_type"] = data_type
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.SERVICE_NOT_FOUND,
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
+
+
+class ServiceBindingError(ToolSetError):
+ """服务绑定错误
+
+ 当服务不属于当前 Agent 时抛出此异常
+
+ Validates: Requirements 6.7 (服务归属验证)
+ """
+
+ def __init__(
+ self,
+ service_name: str,
+ agent_id: str,
+ reason: Optional[str] = None,
+ **kwargs
+ ):
+ message = f"服务绑定错误:服务 '{service_name}' 不属于 Agent '{agent_id}'"
+ if reason:
+ message += f"。原因: {reason}"
+
+ details = {
+ "service_name": service_name,
+ "agent_id": agent_id
+ }
+ if reason:
+ details["reason"] = reason
+ details.update(kwargs.get("details", {}))
+
+ super().__init__(
+ message=message,
+ error_code=ErrorCode.AUTHORIZATION_FAILED,
+ field="service_name",
+ details=details,
+ **{k: v for k, v in kwargs.items() if k != "details"}
+ )
-class ServiceNotFoundError(MCPStoreError):
- """服务不存在"""
- pass
-class InvalidConfigError(MCPStoreError):
- """配置无效"""
- pass
-class DeleteServiceError(MCPStoreError):
- """删除服务失败"""
- pass
diff --git a/src/mcpstore/core/hub/__init__.py b/src/mcpstore/core/hub/__init__.py
new file mode 100644
index 00000000..670e3448
--- /dev/null
+++ b/src/mcpstore/core/hub/__init__.py
@@ -0,0 +1,27 @@
+"""
+Hub MCP 服务暴露模块
+
+将 MCPStore 对象(Store/Agent/ServiceProxy)暴露为标准 MCP 服务。
+基于 FastMCP 框架,提供薄包装层。
+"""
+
+from .exceptions import (
+ HubMCPError,
+ ServerAlreadyRunningError,
+ ServerNotRunningError,
+ ToolExecutionError,
+ PortBindingError,
+)
+from .server import HubMCPServer
+from .types import HubMCPStatus, HubMCPConfig
+
+__all__ = [
+ "HubMCPServer",
+ "HubMCPStatus",
+ "HubMCPConfig",
+ "HubMCPError",
+ "ServerAlreadyRunningError",
+ "ServerNotRunningError",
+ "ToolExecutionError",
+ "PortBindingError",
+]
diff --git a/src/mcpstore/core/hub/exceptions.py b/src/mcpstore/core/hub/exceptions.py
new file mode 100644
index 00000000..dec3af03
--- /dev/null
+++ b/src/mcpstore/core/hub/exceptions.py
@@ -0,0 +1,49 @@
+"""
+Hub MCP Exceptions Module
+Hub MCP 异常模块 - 定义 Hub MCP 相关的异常类
+"""
+
+
+class HubMCPError(Exception):
+ """
+ Hub MCP 错误基类
+
+ 所有 Hub MCP 相关的异常都继承自此类。
+ """
+ pass
+
+
+class ServerAlreadyRunningError(HubMCPError):
+ """
+ 服务器已在运行错误
+
+ 当尝试启动一个已经在运行的服务器时抛出。
+ """
+ pass
+
+
+class ServerNotRunningError(HubMCPError):
+ """
+ 服务器未运行错误
+
+ 当尝试对未运行的服务器执行操作时抛出。
+ """
+ pass
+
+
+class ToolExecutionError(HubMCPError):
+ """
+ 工具执行错误
+
+ 当工具调用失败时抛出。
+ """
+ pass
+
+
+class PortBindingError(HubMCPError):
+ """
+ 端口绑定错误
+
+ 当无法绑定到指定端口时抛出。
+ """
+ pass
diff --git a/src/mcpstore/core/hub/server.py b/src/mcpstore/core/hub/server.py
new file mode 100644
index 00000000..e395d111
--- /dev/null
+++ b/src/mcpstore/core/hub/server.py
@@ -0,0 +1,508 @@
+"""
+Hub MCP Server Module
+Hub MCP 服务器模块 - 将 MCPStore 对象暴露为 MCP 服务
+"""
+
+import asyncio
+import keyword
+import logging
+import threading
+from contextlib import suppress
+from typing import Union, Optional, Literal, Any, Callable, TYPE_CHECKING
+
+from .exceptions import (
+ ServerAlreadyRunningError,
+ ServerNotRunningError,
+ PortBindingError,
+)
+from .types import HubMCPConfig, HubMCPStatus
+
+if TYPE_CHECKING:
+ from ..context.base_context import MCPStoreContext
+ from ..context.service_proxy import ServiceProxy
+ from mcpstore.core.models.tool import ToolInfo
+
+logger = logging.getLogger(__name__)
+
+
+class HubMCPServer:
+ """
+ Hub MCP 服务器
+
+ 将 MCPStore 对象暴露为标准 MCP 服务。
+ 基于 FastMCP 框架,提供薄包装层。
+
+ 核心理念:
+ - 薄包装:直接使用 FastMCP 的能力
+ - 工具转换:将 MCPStore 工具转换为 FastMCP 工具
+ - 透传调用:工具调用直接转发到原始对象
+
+ 支持的对象类型:
+ - Store 对象(MCPStoreContext with agent_id=None)
+ - Agent 对象(MCPStoreContext with agent_id)
+ - ServiceProxy 对象
+ """
+
+ def __init__(
+ self,
+ exposed_object: Union['MCPStoreContext', 'ServiceProxy'],
+ transport: Literal["http", "sse", "stdio"] = "http",
+ port: Optional[int] = None,
+ host: str = "0.0.0.0",
+ path: str = "/mcp",
+ **fastmcp_kwargs
+ ):
+ """
+ 初始化 Hub MCP 服务器
+
+ Args:
+ exposed_object: 要暴露的对象(Store/Agent/ServiceProxy)
+ transport: 传输协议,可选 "http"、"sse"、"stdio"
+ port: 端口号(仅 http/sse),None 为自动分配
+ host: 监听地址(仅 http/sse),默认 "0.0.0.0"
+ path: 端点路径(仅 http),默认 "/mcp"
+ **fastmcp_kwargs: 传递给 FastMCP 的其他参数(如 auth)
+
+ Example:
+ # 暴露 Store 对象
+ store = MCPStore.setup_store()
+ hub = store.for_store().hub_mcp(port=8000)
+
+ # 暴露 Agent 对象
+ agent = store.for_agent("my-agent")
+ hub = agent.hub_mcp(transport="sse", port=8001)
+
+ # 暴露 ServiceProxy 对象
+ service = agent.find_service("weather")
+ hub = service.hub_mcp(transport="stdio")
+ """
+ # 保存暴露对象
+ self._exposed_object = exposed_object
+
+ # 创建配置对象
+ self._config = HubMCPConfig(
+ transport=transport,
+ port=port,
+ host=host,
+ path=path,
+ fastmcp_kwargs=fastmcp_kwargs
+ )
+
+ # 初始化状态
+ self._status = HubMCPStatus.INITIALIZING
+ self._fastmcp: Optional[Any] = None # FastMCP 实例
+ self._server_task: Optional[asyncio.Task] = None # 服务器任务
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._background_thread: Optional[threading.Thread] = None
+
+ logger.info(
+ f"[HubMCPServer] Initializing - "
+ f"object_type={type(exposed_object).__name__}, "
+ f"transport={transport}, "
+ f"port={port or 'auto-assign'}"
+ )
+
+ # 创建 FastMCP 服务器
+ self._create_fastmcp_server()
+
+ # 注册工具
+ self._register_tools()
+
+ # 初始化完成,设置为停止状态
+ self._status = HubMCPStatus.STOPPED
+
+ logger.info(
+ f"[HubMCPServer] Initialization completed - "
+ f"server_name={self._generate_server_name()}, "
+ f"status={self._status.value}"
+ )
+
+ def _generate_server_name(self) -> str:
+ """
+ 生成服务器名称
+
+ 根据暴露对象的类型生成合适的服务器名称:
+ - Agent 对象 → "MCPStore-Agent-{agent_id}"
+ - ServiceProxy 对象 → "MCPStore-Service-{service_name}"
+ - Store 对象 → "MCPStore-Store"
+
+ Returns:
+ str: 生成的服务器名称
+ """
+ try:
+ # 检查是否是 Agent 对象(有 _agent_id 属性且不为 None)
+ if hasattr(self._exposed_object, '_agent_id') and self._exposed_object._agent_id:
+ agent_id = self._exposed_object._agent_id
+ server_name = f"MCPStore-Agent-{agent_id}"
+ logger.debug(f"[HubMCPServer] [NAME] Generated Agent server name: {server_name}")
+ return server_name
+
+ # 检查是否是 ServiceProxy 对象(有 service_name 属性)
+ if hasattr(self._exposed_object, 'service_name'):
+ service_name = self._exposed_object.service_name
+ server_name = f"MCPStore-Service-{service_name}"
+ logger.debug(f"[HubMCPServer] [NAME] Generated ServiceProxy server name: {server_name}")
+ return server_name
+
+ # 默认为 Store 对象
+ server_name = "MCPStore-Store"
+ logger.debug(f"[HubMCPServer] [NAME] Generated Store server name: {server_name}")
+ return server_name
+
+ except Exception as e:
+ logger.warning(f"[HubMCPServer] [WARN] Failed to generate server name: {e}, using default name")
+ return "MCPStore-Hub"
+
+ def _create_fastmcp_server(self) -> None:
+ """
+ 创建 FastMCP 服务器实例
+
+ 使用生成的服务器名称和配置参数创建 FastMCP 实例。
+ """
+ try:
+ # 导入 FastMCP
+ from fastmcp import FastMCP
+
+ # 生成服务器名称
+ server_name = self._generate_server_name()
+
+ # 创建 FastMCP 实例
+ self._fastmcp = FastMCP(
+ name=server_name,
+ **self._config.fastmcp_kwargs
+ )
+
+ logger.info(f"[HubMCPServer] [SUCCESS] FastMCP server created successfully: {server_name}")
+
+ except ImportError as e:
+ logger.error(f"[HubMCPServer] [ERROR] Unable to import FastMCP: {e}")
+ raise ImportError(
+ "FastMCP is not installed. Please run: uv add fastmcp"
+ ) from e
+ except Exception as e:
+ logger.error(f"[HubMCPServer] [ERROR] Failed to create FastMCP server: {e}")
+ raise
+
+ def _register_tools(self) -> None:
+ """
+ 注册所有工具到 FastMCP
+
+ 从暴露对象获取工具列表,为每个工具创建代理函数,
+ 然后使用 FastMCP 的 @tool 装饰器注册。
+ """
+ try:
+ # 获取工具列表
+ tools = self._exposed_object.list_tools()
+
+ logger.info(f"[HubMCPServer] [REGISTER] Starting to register tools, total {len(tools)}")
+
+ # 为每个工具创建代理函数并注册
+ registered_count = 0
+ failed_count = 0
+
+ for tool_info in tools:
+ try:
+ # 创建代理工具
+ proxy_tool = self._create_proxy_tool(tool_info)
+
+ annotations = None
+ schema = getattr(tool_info, "inputSchema", None)
+ if schema and isinstance(schema, dict):
+ annotations = {"arguments": schema}
+
+ meta = {
+ "service_name": getattr(tool_info, "service_name", None),
+ "service_global_name": getattr(tool_info, "service_global_name", None),
+ "client_id": getattr(tool_info, "client_id", None),
+ }
+
+ description = tool_info.description or f"工具: {tool_info.name}"
+ decorator_kwargs = {
+ "name": tool_info.name,
+ "description": description,
+ "meta": {k: v for k, v in meta.items() if v is not None},
+ }
+ if annotations:
+ decorator_kwargs["annotations"] = annotations
+
+ decorator = self._fastmcp.tool(**decorator_kwargs)
+ decorator(proxy_tool)
+
+ registered_count += 1
+ logger.debug(f"[HubMCPServer] [SUCCESS] Tool registered successfully: {tool_info.name}")
+
+ except Exception as e:
+ failed_count += 1
+ logger.warning(
+ f"[HubMCPServer] [WARN] Tool registration failed: {tool_info.name}, "
+ f"error: {e}"
+ )
+ # 单个工具注册失败不影响其他工具
+ continue
+
+ logger.info(
+ f"[HubMCPServer] [COMPLETE] Tool registration completed - "
+ f"successful: {registered_count}, failed: {failed_count}"
+ )
+
+ except Exception as e:
+ logger.error(f"[HubMCPServer] [ERROR] Tool registration failed: {e}")
+ raise
+
+ def _create_proxy_tool(self, tool_info: 'ToolInfo') -> Callable:
+ """
+ 创建代理工具函数
+
+ 为指定的工具创建一个异步代理函数,该函数会将调用转发到
+ 原始对象的 call_tool_async 方法。
+
+ Args:
+ tool_info: 工具信息对象
+
+ Returns:
+ Callable: 代理函数,可以被 FastMCP 注册
+ """
+ schema = getattr(tool_info, "inputSchema", {}) or {}
+ properties = schema.get("properties") or {}
+ required = set(schema.get("required") or [])
+
+ params_code: list[str] = []
+ arg_lines: list[str] = []
+
+ for original_name, prop_schema in properties.items():
+ safe_name = self._sanitize_param_name(original_name)
+
+ if original_name in required:
+ params_code.append(f"{safe_name}")
+ else:
+ default_value = prop_schema.get("default", None)
+ params_code.append(f"{safe_name}={repr(default_value)}")
+
+ arg_lines.append(f" arguments['{original_name}'] = {safe_name}")
+
+ params_signature = ", ".join(params_code)
+ body_lines = [" arguments = {}"]
+ body_lines.extend(arg_lines)
+ body_lines.append(" return await __call_tool(tool_name=__tool_name, args=arguments)")
+
+ function_code = "async def handler({signature}):\n{body}\n".format(
+ signature=params_signature,
+ body="\n".join(body_lines) if body_lines else " return await __call_tool(tool_name=__tool_name, args={})",
+ )
+
+ namespace = {
+ "__call_tool": self._exposed_object.call_tool_async,
+ "__tool_name": tool_info.name,
+ }
+
+ try:
+ exec(function_code, namespace)
+ except Exception as exc: # noqa: BLE001
+ logger.error(f"[HubMCPServer] Failed to generate proxy function for '{tool_info.name}': {exc}")
+ raise
+
+ proxy_tool = namespace["handler"]
+ proxy_tool.__name__ = tool_info.name
+ proxy_tool.__doc__ = (tool_info.description or f"工具: {tool_info.name}")
+
+ return proxy_tool
+
+ def _sanitize_param_name(self, name: str) -> str:
+ if not isinstance(name, str):
+ raise ValueError(f"Illegal parameter name: {name}")
+ if not name.isidentifier() or keyword.iskeyword(name):
+ raise ValueError(f"Tool parameter name '{name}' is not a valid Python identifier")
+ return name
+
+ @property
+ def status(self) -> HubMCPStatus:
+ """
+ 获取服务器状态
+
+ Returns:
+ HubMCPStatus: 当前服务器状态
+ """
+ return self._status
+
+ @property
+ def is_running(self) -> bool:
+ """
+ 检查服务器是否运行中
+
+ Returns:
+ bool: 如果服务器正在运行返回 True,否则返回 False
+ """
+ return self._status == HubMCPStatus.RUNNING
+
+ @property
+ def endpoint_url(self) -> str:
+ """
+ 获取服务器端点 URL
+
+ 根据传输协议返回不同格式的 URL:
+ - stdio: "stdio://local"
+ - sse: "http://{host}:{port}/sse"
+ - http: "http://{host}:{port}{path}"
+
+ Returns:
+ str: 服务器端点 URL
+ """
+ if self._config.transport == "stdio":
+ return "stdio://local"
+ elif self._config.transport == "sse":
+ return f"http://{self._config.host}:{self._config.port}/sse"
+ else: # http
+ return f"http://{self._config.host}:{self._config.port}{self._config.path}"
+
+ def __repr__(self) -> str:
+ """字符串表示"""
+ return (
+ f"HubMCPServer("
+ f"object={type(self._exposed_object).__name__}, "
+ f"transport={self._config.transport}, "
+ f"status={self._status.value}, "
+ f"endpoint={self.endpoint_url}"
+ f")"
+ )
+
+ # ---- Lifecycle helpers -------------------------------------------------
+
+ def _get_transport_kwargs(self) -> dict[str, Any]:
+ """根据传输协议构造 FastMCP 运行参数。"""
+ transport = self._config.transport
+ if transport in {"http", "sse", "streamable-http"}:
+ kwargs: dict[str, Any] = {}
+ if self._config.host:
+ kwargs["host"] = self._config.host
+ if self._config.port is not None:
+ kwargs["port"] = self._config.port
+ if transport in {"http", "sse"} and self._config.path:
+ kwargs["path"] = self._config.path
+ return kwargs
+ return {}
+
+ async def _run_server(self, show_banner: bool) -> None:
+ """启动 FastMCP 服务器的核心协程。"""
+ if self.is_running:
+ raise ServerAlreadyRunningError("Hub MCP server is already running")
+
+ self._status = HubMCPStatus.RUNNING
+ try:
+ await self._fastmcp.run_async(
+ transport=self._config.transport,
+ show_banner=show_banner,
+ **self._get_transport_kwargs(),
+ )
+ except asyncio.CancelledError:
+ logger.info("[HubMCPServer] [STOP] Received stop signal, shutting down")
+ self._status = HubMCPStatus.STOPPED
+ raise
+ except OSError as exc:
+ self._status = HubMCPStatus.ERROR
+ raise PortBindingError(
+ f"无法绑定端口 {self._config.port}: {exc}"
+ ) from exc
+ except Exception as exc: # noqa: BLE001
+ self._status = HubMCPStatus.ERROR
+ logger.error(f"[HubMCPServer] [ERROR] Server run failed: {exc}")
+ raise
+ else:
+ self._status = HubMCPStatus.STOPPED
+
+ async def start_async(self, show_banner: bool = False) -> asyncio.Task:
+ """在当前事件循环中以后台任务形式启动服务器。"""
+ loop = asyncio.get_running_loop()
+ if self._server_task and not self._server_task.done():
+ raise ServerAlreadyRunningError("Hub MCP server is already running")
+
+ self._server_task = loop.create_task(
+ self._run_server(show_banner=show_banner),
+ name=f"HubMCPServer({self._generate_server_name()})",
+ )
+ return self._server_task
+
+ def start(self, *, block: bool = False, show_banner: bool = False) -> "HubMCPServer":
+ """
+ 启动服务器。
+
+ block=False 时在后台事件循环运行;block=True 会阻塞当前线程直到服务器退出。
+ """
+ self._start_in_background(show_banner=show_banner)
+ if block:
+ self.wait()
+ return self
+
+ def _start_in_background(self, show_banner: bool) -> None:
+ if self._background_thread and self._background_thread.is_alive():
+ raise ServerAlreadyRunningError("Hub MCP server is already running in background")
+
+ loop = asyncio.new_event_loop()
+ self._loop = loop
+
+ def _target() -> None:
+ asyncio.set_event_loop(loop)
+ self._server_task = loop.create_task(self._run_server(show_banner=show_banner))
+ try:
+ loop.run_until_complete(self._server_task)
+ except asyncio.CancelledError:
+ logger.debug("[HubMCPServer] [CANCEL] Background task cancelled")
+ finally:
+ self._server_task = None
+ self._loop = None
+ self._background_thread = None
+ loop.close()
+
+ thread = threading.Thread(
+ target=_target,
+ name=f"HubMCPServer-{self._generate_server_name()}",
+ daemon=True,
+ )
+ self._background_thread = thread
+ thread.start()
+
+ async def stop_async(self) -> None:
+ """在当前事件循环中停止服务器。"""
+ if not self._server_task:
+ raise ServerNotRunningError("Hub MCP server is not running")
+
+ self._status = HubMCPStatus.STOPPING
+ self._server_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await self._server_task
+ self._server_task = None
+ self._status = HubMCPStatus.STOPPED
+
+ def stop(self, timeout: float | None = None) -> None:
+ """停止后台运行的服务器。"""
+ if self._loop and self._server_task:
+ self._status = HubMCPStatus.STOPPING
+ future = asyncio.run_coroutine_threadsafe(self._cancel_task(), self._loop)
+ future.result(timeout)
+ if self._background_thread:
+ self._background_thread.join(timeout)
+ self._status = HubMCPStatus.STOPPED
+ return
+
+ if self._server_task and not self._server_task.done():
+ raise RuntimeError("Hub MCP is running in the current event loop, please use stop_async()")
+
+ raise ServerNotRunningError("Hub MCP server is not running")
+
+ async def _cancel_task(self) -> None:
+ if self._server_task:
+ self._server_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await self._server_task
+ self._server_task = None
+
+ def restart(self, *, block: bool = False, show_banner: bool = False) -> "HubMCPServer":
+ """重新启动服务器。"""
+ if self.is_running:
+ self.stop()
+ return self.start(block=block, show_banner=show_banner)
+
+ def wait(self, timeout: float | None = None) -> None:
+ """阻塞当前线程直到后台服务器退出。"""
+ if not self._background_thread:
+ raise ServerNotRunningError("Hub MCP server is not running in background")
+ self._background_thread.join(timeout)
diff --git a/src/mcpstore/core/hub/types.py b/src/mcpstore/core/hub/types.py
new file mode 100644
index 00000000..99b42e00
--- /dev/null
+++ b/src/mcpstore/core/hub/types.py
@@ -0,0 +1,68 @@
+"""
+Hub MCP Types Module
+Hub MCP 类型定义模块 - 定义 Hub MCP 相关的数据类型和枚举
+"""
+
+import socket
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional, Literal, Any, Dict
+
+
+class HubMCPStatus(Enum):
+ """
+ Hub MCP 服务器状态枚举
+
+ 定义 Hub MCP 服务器的所有可能状态。
+ """
+
+ INITIALIZING = "initializing" # 初始化中
+ RUNNING = "running" # 运行中
+ STOPPING = "stopping" # 停止中
+ STOPPED = "stopped" # 已停止
+ ERROR = "error" # 错误状态
+
+
+@dataclass
+class HubMCPConfig:
+ """
+ Hub MCP 配置数据类
+
+ 定义 Hub MCP 服务器的配置参数。
+
+ Attributes:
+ transport: 传输协议,可选 "http"、"sse"、"stdio"
+ port: 端口号(仅 http/sse),None 为自动分配
+ host: 监听地址(仅 http/sse),默认 "0.0.0.0"
+ path: 端点路径(仅 http),默认 "/mcp"
+ fastmcp_kwargs: 传递给 FastMCP 的其他参数
+ """
+
+ transport: Literal["http", "sse", "stdio"] = "http"
+ port: Optional[int] = None
+ host: str = "0.0.0.0"
+ path: str = "/mcp"
+ fastmcp_kwargs: Dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ """
+ 初始化后处理
+
+ 自动分配端口(如果需要)。
+ """
+ # 自动分配端口
+ if self.port is None and self.transport in ["http", "sse"]:
+ self.port = self._find_available_port()
+
+ def _find_available_port(self) -> int:
+ """
+ 查找可用端口
+
+ Returns:
+ int: 可用的端口号
+ """
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(('', 0))
+ s.listen(1)
+ port = s.getsockname()[1]
+ return port
diff --git a/src/mcpstore/core/infrastructure/__init__.py b/src/mcpstore/core/infrastructure/__init__.py
new file mode 100644
index 00000000..8eb8c741
--- /dev/null
+++ b/src/mcpstore/core/infrastructure/__init__.py
@@ -0,0 +1,13 @@
+"""
+基础设施层模块
+
+包含依赖注入容器和其他基础设施组件:
+- ServiceContainer: 依赖注入容器
+"""
+
+from .container import ServiceContainer
+
+__all__ = [
+ "ServiceContainer",
+]
+
diff --git a/src/mcpstore/core/infrastructure/container.py b/src/mcpstore/core/infrastructure/container.py
new file mode 100644
index 00000000..8c31ab51
--- /dev/null
+++ b/src/mcpstore/core/infrastructure/container.py
@@ -0,0 +1,184 @@
+"""
+依赖注入容器 - 管理所有组件的创建和依赖关系
+
+职责:
+1. 创建和管理所有组件的生命周期
+2. 处理组件之间的依赖关系
+3. 提供统一的访问接口
+"""
+
+import logging
+from typing import TYPE_CHECKING
+
+from mcpstore.core.application.service_application_service import ServiceApplicationService
+from mcpstore.core.domain.cache_manager import CacheManager
+from mcpstore.core.domain.connection_manager import ConnectionManager
+from mcpstore.core.domain.health_monitor import HealthMonitor
+from mcpstore.core.domain.lifecycle_manager import LifecycleManager
+from mcpstore.core.domain.persistence_manager import PersistenceManager
+from mcpstore.core.domain.reconnection_scheduler import ReconnectionScheduler
+from mcpstore.core.events.event_bus import EventBus
+
+if TYPE_CHECKING:
+ from mcpstore.core.registry.core_registry import CoreRegistry
+ from mcpstore.core.registry.agent_locks import AgentLocks
+ from mcpstore.core.configuration.unified_config import UnifiedConfigManager
+ from mcpstore.core.configuration.config_processor import ConfigProcessor
+ from mcpstore.core.integration.local_service_adapter import LocalServiceManagerAdapter
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceContainer:
+ """
+ 服务容器 - 依赖注入容器
+
+ 负责创建和管理所有组件的生命周期
+ """
+
+ def __init__(
+ self,
+ registry: 'CoreRegistry',
+ agent_locks: 'AgentLocks',
+ config_manager: 'UnifiedConfigManager',
+ config_processor: 'ConfigProcessor',
+ local_service_manager: 'LocalServiceManagerAdapter',
+ global_agent_store_id: str,
+ enable_event_history: bool = False
+ ):
+ self._registry = registry
+ self._agent_locks = agent_locks
+ self._config_manager = config_manager
+ self._config_processor = config_processor
+ self._local_service_manager = local_service_manager
+ self._global_agent_store_id = global_agent_store_id
+
+ # 创建事件总线(核心)
+ # 事件总线:启用可选的 handler 超时(安全兜底)
+ self._event_bus = EventBus(enable_history=enable_event_history, handler_timeout=None)
+
+ # 创建领域服务
+ self._cache_manager = CacheManager(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ agent_locks=self._agent_locks
+ )
+
+ # 获取生命周期配置(自动处理异步上下文和fallback)
+ from mcpstore.config.toml_config import get_lifecycle_config_with_defaults
+ lifecycle_config = get_lifecycle_config_with_defaults()
+
+ # 获取HTTP超时配置
+ from mcpstore.config.config_defaults import StandaloneConfigDefaults
+ http_timeout_seconds = float(StandaloneConfigDefaults().http_timeout_seconds)
+ logger.debug(f"[CONTAINER] HTTP timeout configured: {http_timeout_seconds} seconds")
+
+ self._lifecycle_manager = LifecycleManager(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ lifecycle_config=lifecycle_config,
+ agent_locks=self._agent_locks
+ )
+
+ self._connection_manager = ConnectionManager(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ config_processor=self._config_processor,
+ local_service_manager=self._local_service_manager,
+ http_timeout_seconds=http_timeout_seconds
+ )
+
+ self._persistence_manager = PersistenceManager(
+ event_bus=self._event_bus,
+ config_manager=self._config_manager
+ )
+
+ self._health_monitor = HealthMonitor(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ lifecycle_config=lifecycle_config,
+ global_agent_store_id=self._global_agent_store_id
+ )
+
+ # 创建重连调度器(使用相同的生命周期配置)
+ self._reconnection_scheduler = ReconnectionScheduler(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ lifecycle_config=lifecycle_config,
+ scan_interval=1.0, # 扫描间隔固定1秒
+ )
+
+ # 创建应用服务
+ self._service_app_service = ServiceApplicationService(
+ event_bus=self._event_bus,
+ registry=self._registry,
+ lifecycle_manager=self._lifecycle_manager,
+ global_agent_store_id=self._global_agent_store_id
+ )
+
+ logger.info("ServiceContainer initialized with all components (including health monitor and reconnection scheduler)")
+
+ @property
+ def event_bus(self) -> EventBus:
+ """获取事件总线"""
+ return self._event_bus
+
+ @property
+ def service_application_service(self) -> ServiceApplicationService:
+ """获取服务应用服务"""
+ return self._service_app_service
+
+ @property
+ def cache_manager(self) -> CacheManager:
+ """获取缓存管理器"""
+ return self._cache_manager
+
+ @property
+ def lifecycle_manager(self) -> LifecycleManager:
+ """获取生命周期管理器"""
+ return self._lifecycle_manager
+
+ @property
+ def connection_manager(self) -> ConnectionManager:
+ """获取连接管理器"""
+ return self._connection_manager
+
+ @property
+ def persistence_manager(self) -> PersistenceManager:
+ """获取持久化管理器"""
+ return self._persistence_manager
+
+ @property
+ def health_monitor(self) -> HealthMonitor:
+ """获取健康监控管理器"""
+ return self._health_monitor
+
+ @property
+ def reconnection_scheduler(self) -> ReconnectionScheduler:
+ """获取重连调度器"""
+ return self._reconnection_scheduler
+
+ async def start(self):
+ """启动所有需要后台运行的组件"""
+ logger.info("Starting ServiceContainer components...")
+
+ # 启动健康监控
+ await self._health_monitor.start()
+
+ # 启动重连调度器
+ await self._reconnection_scheduler.start()
+
+ logger.info("ServiceContainer components started")
+
+ async def stop(self):
+ """停止所有组件"""
+ logger.info("Stopping ServiceContainer components...")
+
+ # 停止健康监控
+ await self._health_monitor.stop()
+
+ # 停止重连调度器
+ await self._reconnection_scheduler.stop()
+
+ logger.info("ServiceContainer components stopped")
+
diff --git a/src/mcpstore/core/integration/__init__.py b/src/mcpstore/core/integration/__init__.py
new file mode 100644
index 00000000..0706d854
--- /dev/null
+++ b/src/mcpstore/core/integration/__init__.py
@@ -0,0 +1,15 @@
+"""
+Integration layer modules for external systems (FastMCP, HTTP transport, OpenAPI, etc.).
+
+This package consolidates previously scattered integration files under a single namespace
+without changing any public APIs. Original modules under mcpstore.core keep thin proxy
+re-exports to maintain full backward compatibility.
+"""
+
+from .fastmcp_integration import FastMCPServiceManager, get_fastmcp_service_manager
+
+__all__ = [
+ "FastMCPServiceManager",
+ "get_fastmcp_service_manager",
+]
+
diff --git a/src/mcpstore/core/integration/deadlock_fix_integration.py b/src/mcpstore/core/integration/deadlock_fix_integration.py
new file mode 100644
index 00000000..af722575
--- /dev/null
+++ b/src/mcpstore/core/integration/deadlock_fix_integration.py
@@ -0,0 +1,333 @@
+"""
+死锁修复集成配置
+
+统一的集成点,用于无缝替换现有组件
+"""
+
+import logging
+from typing import Any, Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class DeadlockFixIntegration:
+ """
+ 死锁修复集成管理器
+
+ 负责协调所有修复组件的集成和替换
+ """
+
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
+ """
+ 初始化集成管理器
+
+ Args:
+ config: 配置选项
+ """
+ self.config = config or {}
+ self._applied_fixes = []
+ self._migration_performed = False
+
+ logger.info("DeadlockFixIntegration initialized")
+
+ def apply_all_fixes(self, registry, service_management, async_helper=None):
+ """
+ 应用所有死锁修复
+
+ Args:
+ registry: 当前注册表实例
+ service_management: 当前服务管理实例
+ async_helper: 当前异步助手实例(可选)
+ """
+ logger.info("Starting deadlock fixes application")
+
+ try:
+ # 1. 替换异步助手
+ if async_helper:
+ self._replace_async_helper(async_helper)
+
+ # 2. 迁移注册表
+ if registry:
+ self._migrate_registry(registry)
+
+ # 3. 迁移服务管理
+ if service_management:
+ self._migrate_service_management(service_management)
+
+ # 4. 应用配置修复
+ self._apply_config_fixes()
+
+ self._migration_performed = True
+ logger.info("All deadlock fixes applied successfully")
+
+ except Exception as e:
+ logger.error(f"Failed to apply deadlock fixes: {e}")
+ raise
+
+ def _replace_async_helper(self, current_helper):
+ """替换异步助手"""
+ logger.info("Replacing async helper with deadlock-safe version")
+
+ try:
+ from ..utils.deadlock_safe_async_helper import get_deadlock_safe_helper
+
+ # 获取死锁安全的助手
+ safe_helper = get_deadlock_safe_helper()
+
+ # 在全局范围内替换
+ import sys
+ for module_name, module in sys.modules.items():
+ if hasattr(module, '_sync_helper') and module._sync_helper is current_helper:
+ module._sync_helper = safe_helper
+ logger.debug(f"Replaced async helper in module: {module_name}")
+
+ self._applied_fixes.append("async_helper_replaced")
+ logger.info("Async helper replacement completed")
+
+ except ImportError as e:
+ logger.error(f"Failed to import deadlock-safe helper: {e}")
+ raise
+
+ def _migrate_registry(self, current_registry):
+ """迁移注册表到异步安全版本"""
+ logger.info("Migrating registry to async-safe version")
+
+ try:
+ # 异步安全注册表已废弃;保持兼容但不再迁移
+ logger.warning("Async-safe registry migration is deprecated; skipping.")
+ return
+
+ except Exception as e:
+ logger.error(f"Failed to migrate registry (deprecated path): {e}")
+ raise
+
+ def _migrate_service_management(self, current_service_management):
+ """迁移服务管理到异步安全版本"""
+ logger.info("Migrating service management to async-safe version")
+
+ try:
+ from ..context.async_safe_service_management import AsyncSafeServiceManagementFactory
+
+ # 创建异步安全服务管理
+ safe_service_management = AsyncSafeServiceManagementFactory.migrate_from_standard_management(
+ current_service_management
+ )
+
+ # 替换服务管理引用
+ self._replace_service_management_references(current_service_management, safe_service_management)
+
+ self._applied_fixes.append("service_management_migrated")
+ logger.info("Service management migration completed")
+
+ except ImportError as e:
+ logger.error(f"Failed to import async-safe service management: {e}")
+ raise
+
+ def _apply_config_fixes(self):
+ """应用配置修复"""
+ logger.info("Applying configuration fixes")
+
+ # 修复1:调整超时配置
+ timeout_config = {
+ "async_operation_timeout": self.config.get("async_timeout", 30.0),
+ "nested_call_detection": True,
+ "max_concurrent_calls": self.config.get("max_concurrent", 10),
+ "cache_enabled": self.config.get("cache_enabled", True),
+ "cache_timeout": self.config.get("cache_timeout", 5.0)
+ }
+
+ # 应用到全局配置
+ try:
+ from ...config.toml_config import get_mcp_config
+ mcp_config = get_mcp_config()
+
+ # 设置死锁修复相关配置
+ mcp_config.set("deadlock_fix.async_timeout", timeout_config["async_operation_timeout"])
+ mcp_config.set("deadlock_fix.nested_call_detection", timeout_config["nested_call_detection"])
+ mcp_config.set("deadlock_fix.max_concurrent", timeout_config["max_concurrent_calls"])
+ mcp_config.set("deadlock_fix.cache_enabled", timeout_config["cache_enabled"])
+ mcp_config.set("deadlock_fix.cache_timeout", timeout_config["cache_timeout"])
+
+ self._applied_fixes.append("config_fixed")
+ logger.info("Configuration fixes applied")
+
+ except Exception as e:
+ logger.warning(f"Failed to apply configuration fixes: {e}")
+
+ def _replace_registry_references(self, old_registry, new_registry):
+ """替换注册表引用"""
+ import sys
+
+ for module_name, module in sys.modules.items():
+ if hasattr(module, '_registry') and module._registry is old_registry:
+ module._registry = new_registry
+ logger.debug(f"Replaced registry in module: {module_name}")
+
+ if hasattr(module, 'registry') and module.registry is old_registry:
+ module.registry = new_registry
+ logger.debug(f"Replaced registry in module: {module_name}")
+
+ def _replace_service_management_references(self, old_service_management, new_service_management):
+ """替换服务管理引用"""
+ import sys
+
+ for module_name, module in sys.modules.items():
+ if hasattr(module, '_service_management') and module._service_management is old_service_management:
+ module._service_management = new_service_management
+ logger.debug(f"Replaced service_management in module: {module_name}")
+
+ if hasattr(module, 'service_management') and module.service_management is old_service_management:
+ module.service_management = new_service_management
+ logger.debug(f"Replaced service_management in module: {module_name}")
+
+ def get_migration_report(self) -> Dict[str, Any]:
+ """获取迁移报告"""
+ return {
+ "migration_performed": self._migration_performed,
+ "applied_fixes": self._applied_fixes,
+ "fixes_count": len(self._applied_fixes),
+ "config": self.config
+ }
+
+ def validate_fixes(self) -> Dict[str, Any]:
+ """验证修复是否成功应用"""
+ validation_results = {
+ "overall_status": "unknown",
+ "individual_checks": {},
+ "issues": []
+ }
+
+ try:
+ # 验证1:检查死锁安全助手
+ try:
+ from ..utils.deadlock_safe_async_helper import get_deadlock_safe_helper
+ helper = get_deadlock_safe_helper()
+ validation_results["individual_checks"]["deadlock_safe_helper"] = "passed"
+ except Exception as e:
+ validation_results["individual_checks"]["deadlock_safe_helper"] = f"failed: {e}"
+ validation_results["issues"].append(f"Deadlock-safe helper issue: {e}")
+
+ # 验证2:检查异步安全注册表(已废弃,标记为跳过)
+ validation_results["individual_checks"]["async_safe_registry"] = "skipped (deprecated)"
+
+ # 验证3:检查异步安全服务管理
+ try:
+ from ..context.async_safe_service_management import AsyncSafeServiceManagement
+ validation_results["individual_checks"]["async_safe_service_management"] = "passed"
+ except Exception as e:
+ validation_results["individual_checks"]["async_safe_service_management"] = f"failed: {e}"
+ validation_results["issues"].append(f"Async-safe service management issue: {e}")
+
+ # 计算总体状态
+ passed_checks = sum(1 for check in validation_results["individual_checks"].values() if check == "passed")
+ total_checks = len(validation_results["individual_checks"])
+
+ if passed_checks == total_checks:
+ validation_results["overall_status"] = "success"
+ elif passed_checks > 0:
+ validation_results["overall_status"] = "partial"
+ else:
+ validation_results["overall_status"] = "failed"
+
+ logger.info(f"Fix validation completed: {validation_results['overall_status']} ({passed_checks}/{total_checks})")
+
+ except Exception as e:
+ validation_results["overall_status"] = "error"
+ validation_results["issues"].append(f"Validation error: {e}")
+ logger.error(f"Fix validation failed: {e}")
+
+ return validation_results
+
+
+class DeadlockFixAutoApplier:
+ """死锁修复自动应用器"""
+
+ @staticmethod
+ def auto_apply_fixes(config: Optional[Dict[str, Any]] = None) -> DeadlockFixIntegration:
+ """
+ 自动应用死锁修复
+
+ Args:
+ config: 配置选项
+
+ Returns:
+ 修复集成管理器实例
+ """
+ logger.info("Auto-applying deadlock fixes")
+
+ integration = DeadlockFixIntegration(config)
+
+ try:
+ # 获取当前系统组件
+ registry = DeadlockFixAutoApplier._get_current_registry()
+ service_management = DeadlockFixAutoApplier._get_current_service_management()
+ async_helper = DeadlockFixAutoApplier._get_current_async_helper()
+
+ # 应用修复
+ integration.apply_all_fixes(registry, service_management, async_helper)
+
+ # 验证修复
+ validation_result = integration.validate_fixes()
+ if validation_result["overall_status"] != "success":
+ logger.warning(f"Fix validation issues: {validation_result['issues']}")
+
+ return integration
+
+ except Exception as e:
+ logger.error(f"Auto-application of deadlock fixes failed: {e}")
+ raise
+
+ @staticmethod
+ def _get_current_registry():
+ """获取当前注册表实例"""
+ try:
+ # 尝试从已知位置获取注册表
+ from ..registry.core_registry import ServiceRegistry
+ # 这里需要根据实际的应用架构来获取注册表实例
+ return None # 占位符,实际使用时需要替换
+ except ImportError:
+ return None
+
+ @staticmethod
+ def _get_current_service_management():
+ """获取当前服务管理实例"""
+ try:
+ from ..context.service_management import ServiceManagement
+ # 这里需要根据实际的应用架构来获取服务管理实例
+ return None # 占位符,实际使用时需要替换
+ except ImportError:
+ return None
+
+ @staticmethod
+ def _get_current_async_helper():
+ """获取当前异步助手实例"""
+ try:
+ from ..bridge import get_async_bridge
+ return get_async_bridge()
+ except Exception:
+ return None
+
+
+# 导出的便捷函数
+def apply_deadlock_fixes(config: Optional[Dict[str, Any]] = None) -> DeadlockFixIntegration:
+ """
+ 应用死锁修复的便捷函数
+
+ Args:
+ config: 配置选项,如 {"async_timeout": 30.0, "max_concurrent": 10}
+
+ Returns:
+ 修复集成管理器实例
+ """
+ return DeadlockFixAutoApplier.auto_apply_fixes(config)
+
+
+def validate_deadlock_fixes() -> Dict[str, Any]:
+ """
+ 验证死锁修复的便捷函数
+
+ Returns:
+ 验证结果
+ """
+ integration = DeadlockFixIntegration()
+ return integration.validate_fixes()
diff --git a/src/mcpstore/core/integration/fastmcp_integration.py b/src/mcpstore/core/integration/fastmcp_integration.py
new file mode 100644
index 00000000..934cd577
--- /dev/null
+++ b/src/mcpstore/core/integration/fastmcp_integration.py
@@ -0,0 +1,246 @@
+"""
+FastMCP Integration Layer
+Provides a clean interface between MCPStore and FastMCP, handling configuration normalization.
+"""
+
+import logging
+import time
+from pathlib import Path
+from typing import Dict, Any, Optional, Tuple
+
+from fastmcp import Client
+
+logger = logging.getLogger(__name__)
+
+class FastMCPServiceManager:
+ """
+ FastMCP Service Manager
+
+ Responsible for converting MCPStore's relaxed configuration to FastMCP standard configuration, and managing FastMCP clients.
+ This is the bridge between MCPStore and FastMCP.
+ """
+
+ def __init__(self, base_work_dir: Optional[Path] = None):
+ """
+ Initialize FastMCP service manager
+
+ Args:
+ base_work_dir: Base working directory for local services
+ """
+ self.base_work_dir = base_work_dir or Path.cwd()
+ self.clients: Dict[str, Client] = {}
+ self.service_configs: Dict[str, Dict[str, Any]] = {}
+ self.service_start_times: Dict[str, float] = {}
+
+ logger.info(f"FastMCPServiceManager initialized with work_dir: {self.base_work_dir}")
+
+ async def start_local_service(self, name: str, config: Dict[str, Any]) -> Tuple[bool, str]:
+ """
+ Start local service (replaces LocalServiceManager.start_local_service)
+
+ Args:
+ name: Service name
+ config: User configuration (relaxed format)
+
+ Returns:
+ Tuple[bool, str]: (Success, message)
+ """
+ try:
+ logger.info(f"Starting local service {name} with FastMCP")
+
+ # 1. Configuration normalization: Convert user configuration to FastMCP standard format
+ fastmcp_config = self._normalize_local_service_config(name, config)
+
+ # 2. Create FastMCP client
+ client = Client(fastmcp_config)
+
+ # 3. Test connection (FastMCP will automatically start process)
+ try:
+ async with client:
+ # FastMCP automatically handles:
+ # - Process startup (subprocess.Popen)
+ # - Environment variable setup
+ # - Working directory setup
+ # - stdin/stdout management
+ await client.ping() # Standard MCP ping
+
+ # Store client and configuration
+ self.clients[name] = client
+ self.service_configs[name] = config
+ self.service_start_times[name] = time.time()
+
+ logger.info(f"Local service {name} started successfully via FastMCP")
+ return True, f"Service started successfully via FastMCP"
+
+ except Exception as e:
+ logger.error(f"FastMCP failed to start service {name}: {e}")
+ return False, f"FastMCP connection failed: {str(e)}"
+
+ except Exception as e:
+ logger.error(f"Failed to start local service {name}: {e}")
+ return False, str(e)
+
+ async def stop_local_service(self, name: str) -> Tuple[bool, str]:
+ """
+ Stop local service (replaces LocalServiceManager.stop_local_service)
+
+ Args:
+ name: Service name
+
+ Returns:
+ Tuple[bool, str]: (Success, message)
+ """
+ try:
+ if name not in self.clients:
+ return False, f"Service {name} not found"
+
+ # FastMCP client will automatically handle process cleanup
+ client = self.clients[name]
+
+ # Clean up records
+ del self.clients[name]
+ if name in self.service_configs:
+ del self.service_configs[name]
+ if name in self.service_start_times:
+ del self.service_start_times[name]
+
+ logger.info(f"Local service {name} stopped successfully")
+ return True, "Service stopped successfully"
+
+ except Exception as e:
+ logger.error(f"Failed to stop local service {name}: {e}")
+ return False, str(e)
+
+ def get_service_status(self, name: str) -> Dict[str, Any]:
+ """
+ Get service status (replaces LocalServiceManager.get_service_status)
+
+ Args:
+ name: Service name
+
+ Returns:
+ Dict[str, Any]: Service status information
+ """
+ if name not in self.clients:
+ return {"status": "not_found"}
+
+ try:
+ # Use FastMCP client to check connection status
+ client = self.clients[name]
+
+ # Simple status check
+ start_time = self.service_start_times.get(name, 0)
+ uptime = time.time() - start_time if start_time > 0 else 0
+
+ return {
+ "status": "running", # FastMCP managed services assumed to be in running state
+ "uptime": uptime,
+ "start_time": start_time,
+ "managed_by": "fastmcp"
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to get service status for {name}: {e}")
+ return {"status": "error", "error": str(e)}
+
+ def list_services(self) -> Dict[str, Dict[str, Any]]:
+ """
+ List all service statuses (replaces LocalServiceManager.list_services)
+
+ Returns:
+ Dict[str, Dict[str, Any]]: Status information of all services
+ """
+ return {name: self.get_service_status(name) for name in self.clients}
+
+ async def cleanup(self):
+ """
+ Clean up all services (replaces LocalServiceManager.cleanup)
+ """
+ logger.info("Cleaning up FastMCP services...")
+
+ # Stop all services
+ for name in list(self.clients.keys()):
+ await self.stop_local_service(name)
+
+ logger.info("FastMCP service cleanup completed")
+
+ def _normalize_local_service_config(self, name: str, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Configuration normalization: Convert MCPStore's relaxed configuration to FastMCP standard configuration
+
+ This is the core value of MCPStore: Allow users to input relaxed format and convert to standard format
+
+ Args:
+ name: Service name
+ config: User configuration (relaxed format)
+
+ Returns:
+ Dict[str, Any]: FastMCP standard configuration
+ """
+ # FastMCP standard configuration format
+ fastmcp_config = {
+ "mcpServers": {
+ name: {}
+ }
+ }
+
+ service_config = fastmcp_config["mcpServers"][name]
+
+ # 1. Handle required fields
+ if "command" not in config:
+ raise ValueError(f"Local service {name} missing required 'command' field")
+
+ service_config["command"] = config["command"]
+
+ # 2. Handle optional fields
+ if "args" in config:
+ service_config["args"] = config["args"]
+
+ # 3. Environment variable handling (simplified version)
+ env = {}
+ if "env" in config:
+ env.update(config["env"])
+
+ # Ensure PYTHONPATH includes working directory
+ if "PYTHONPATH" not in env:
+ env["PYTHONPATH"] = str(self.base_work_dir)
+ else:
+ env["PYTHONPATH"] = f"{self.base_work_dir}{Path.pathsep}{env['PYTHONPATH']}"
+
+ service_config["env"] = env
+
+ # 4. Working directory handling
+ working_dir = config.get("working_dir")
+ if working_dir:
+ # If relative path, relative to base_work_dir
+ work_path = Path(working_dir)
+ if not work_path.is_absolute():
+ work_path = self.base_work_dir / work_path
+ service_config["cwd"] = str(work_path.resolve())
+ else:
+ service_config["cwd"] = str(self.base_work_dir)
+
+ logger.debug(f"Normalized config for {name}: {fastmcp_config}")
+ return fastmcp_config
+
+# Global instance (maintain same interface as LocalServiceManager)
+_fastmcp_service_manager: Optional[FastMCPServiceManager] = None
+
+def get_fastmcp_service_manager(base_work_dir: Optional[Path] = None) -> FastMCPServiceManager:
+ """
+ Get global FastMCP service manager instance (replaces get_local_service_manager)
+
+ Args:
+ base_work_dir: Base working directory
+
+ Returns:
+ FastMCPServiceManager: Global instance
+ """
+ global _fastmcp_service_manager
+ if _fastmcp_service_manager is None:
+ _fastmcp_service_manager = FastMCPServiceManager(base_work_dir)
+ elif base_work_dir and _fastmcp_service_manager.base_work_dir != base_work_dir:
+ # If working directory is different, create new instance
+ _fastmcp_service_manager = FastMCPServiceManager(base_work_dir)
+ return _fastmcp_service_manager
+
diff --git a/src/mcpstore/core/integration/local_service_adapter.py b/src/mcpstore/core/integration/local_service_adapter.py
new file mode 100644
index 00000000..26c4df40
--- /dev/null
+++ b/src/mcpstore/core/integration/local_service_adapter.py
@@ -0,0 +1,193 @@
+"""
+Local Service Adapter
+Provides backward compatibility while transitioning from LocalServiceManager to FastMCP.
+"""
+
+import logging
+from pathlib import Path
+from typing import Dict, Any, Optional, Tuple
+
+from .fastmcp_integration import FastMCPServiceManager
+
+logger = logging.getLogger(__name__)
+
+class LocalServiceManagerAdapter:
+ """
+ LocalServiceManager Adapter
+
+ Provides the same interface as the original LocalServiceManager, but internally uses FastMCP implementation.
+ This ensures backward compatibility while gradually migrating to FastMCP.
+ """
+
+ def __init__(self, base_work_dir: str = None):
+ """
+ Initialize adapter
+
+ Args:
+ base_work_dir: Base working directory
+ """
+ self.base_work_dir = Path(base_work_dir or Path.cwd())
+
+ # Use FastMCP service manager as underlying implementation
+ self.fastmcp_manager = FastMCPServiceManager(self.base_work_dir)
+
+ # Health check configuration
+ self.health_check_interval = 30
+ self.max_restart_attempts = 3
+ self.restart_delay = 5
+
+ # Monitoring tasks
+ self._health_check_task = None
+ self._monitor_started = False
+
+ logger.info(f"LocalServiceManagerAdapter initialized (using FastMCP backend)")
+
+ async def start_local_service(self, name: str, config: Dict[str, Any]) -> Tuple[bool, str]:
+ """
+ Start local service (compatible with LocalServiceManager interface)
+
+ Args:
+ name: Service name
+ config: Service configuration
+
+ Returns:
+ Tuple[bool, str]: (Success, message)
+ """
+ logger.info(f"[Adapter] Starting local service {name} via FastMCP")
+
+ # Delegate to FastMCP manager
+ return await self.fastmcp_manager.start_local_service(name, config)
+
+ async def stop_local_service(self, name: str) -> Tuple[bool, str]:
+ """
+ Stop local service (compatible with LocalServiceManager interface)
+
+ Args:
+ name: Service name
+
+ Returns:
+ Tuple[bool, str]: (Success, message)
+ """
+ logger.info(f"[Adapter] Stopping local service {name} via FastMCP")
+
+ # Delegate to FastMCP manager
+ return await self.fastmcp_manager.stop_local_service(name)
+
+ def get_service_status(self, name: str) -> Dict[str, Any]:
+ """
+ Get service status (compatible with LocalServiceManager interface)
+
+ Args:
+ name: Service name
+
+ Returns:
+ Dict[str, Any]: Service status information
+ """
+ # Delegate to FastMCP manager
+ status = self.fastmcp_manager.get_service_status(name)
+
+ # Convert to original LocalServiceManager status format
+ if status.get("status") == "not_found":
+ return {"status": "not_found"}
+ elif status.get("status") == "error":
+ return {"status": "stopped", "error": status.get("error")}
+ else:
+ return {
+ "status": "running",
+ "pid": 0, # Process managed by FastMCP, PID not exposed
+ "start_time": status.get("start_time", 0),
+ "restart_count": 0, # FastMCP handles restarts automatically
+ "uptime": status.get("uptime", 0),
+ "managed_by": "fastmcp"
+ }
+
+ def list_services(self) -> Dict[str, Dict[str, Any]]:
+ """
+ List all service statuses (compatible with LocalServiceManager interface)
+
+ Returns:
+ Dict[str, Dict[str, Any]]: Status information of all services
+ """
+ # Delegate to FastMCP manager and convert format
+ fastmcp_services = self.fastmcp_manager.list_services()
+
+ # Convert to original LocalServiceManager format
+ result = {}
+ for name, status in fastmcp_services.items():
+ result[name] = self.get_service_status(name)
+
+ return result
+
+ async def cleanup(self):
+ """
+ Clean up all services (compatible with LocalServiceManager interface)
+ """
+ logger.info("[Adapter] Cleaning up services via FastMCP")
+
+ # Stop health monitoring (compatibility)
+ if self._health_check_task:
+ self._health_check_task.cancel()
+
+ # Delegate to FastMCP manager
+ await self.fastmcp_manager.cleanup()
+
+ # Health monitoring, process checking, service restart and other features are now fully handled by FastMCP automatically
+
+ async def start_health_monitoring(self):
+ """Start health monitoring (FastMCP handles automatically)"""
+ logger.info("[Adapter] Health monitoring delegated to FastMCP")
+ self._monitor_started = True
+
+ # _prepare_environment and _resolve_working_dir methods have been removed
+ # Environment variable and working directory handling are now fully handled by FastMCP configuration normalization
+
+# Global instance (maintains same interface as original LocalServiceManager)
+_local_service_manager_adapter: Optional[LocalServiceManagerAdapter] = None
+
+
+def get_local_service_manager() -> LocalServiceManagerAdapter:
+ """
+ Get global local service manager instance (adapter version)
+
+ This function replaces the original get_local_service_manager but returns an adapter instance.
+ The adapter provides the same interface but uses FastMCP implementation internally.
+
+ Returns:
+ LocalServiceManagerAdapter: Global adapter instance
+ """
+ global _local_service_manager_adapter
+ if _local_service_manager_adapter is None:
+ _local_service_manager_adapter = LocalServiceManagerAdapter()
+ return _local_service_manager_adapter
+
+
+def set_local_service_manager_work_dir(base_work_dir: str):
+ """
+ Set working directory for local service manager (used for data space mode)
+
+ Args:
+ base_work_dir: Base working directory
+ """
+ global _local_service_manager_adapter
+ _local_service_manager_adapter = LocalServiceManagerAdapter(base_work_dir)
+ logger.info(f"LocalServiceManagerAdapter work directory set to: {base_work_dir}")
+
+# Export adapter class
+LocalServiceManager = LocalServiceManagerAdapter
+
+# LocalServiceProcess class (for type compatibility)
+from dataclasses import dataclass
+import subprocess
+
+@dataclass
+class LocalServiceProcess:
+ """Local service process information"""
+ name: str
+ process: Optional[subprocess.Popen] = None
+ config: Dict[str, Any] = None
+ start_time: float = 0
+ pid: int = 0
+ status: str = "running"
+ restart_count: int = 0
+ last_health_check: float = 0
+
diff --git a/src/mcpstore/core/integration/openapi_integration.py b/src/mcpstore/core/integration/openapi_integration.py
new file mode 100644
index 00000000..ca608c6b
--- /dev/null
+++ b/src/mcpstore/core/integration/openapi_integration.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+OpenAPI Deep Integration
+Automated API conversion, custom route mapping, intelligent MCP component name generation
+"""
+
+import logging
+import re
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Dict, List, Any, Optional, Tuple
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+class MCPComponentType(Enum):
+ """MCP component types"""
+ TOOL = "tool"
+ RESOURCE = "resource"
+ RESOURCE_TEMPLATE = "resource_template"
+
+class HTTPMethod(Enum):
+ """HTTP methods"""
+ GET = "GET"
+ POST = "POST"
+ PUT = "PUT"
+ DELETE = "DELETE"
+ PATCH = "PATCH"
+ HEAD = "HEAD"
+ OPTIONS = "OPTIONS"
+
+@dataclass
+class RouteMapping:
+ """Route mapping configuration"""
+ path_pattern: str # Path pattern, supports regular expressions
+ method: Optional[HTTPMethod] = None # HTTP method, None means match all methods
+ mcp_type: MCPComponentType = MCPComponentType.TOOL # MCP component type to map to
+ name_template: Optional[str] = None # Name template
+ description_template: Optional[str] = None # Description template
+ tags: List[str] = field(default_factory=list) # Tags
+
+@dataclass
+class OpenAPIServiceConfig:
+ """OpenAPI service configuration"""
+ name: str
+ spec_url: str
+ base_url: Optional[str] = None
+ auth_config: Optional[Dict[str, Any]] = None
+ route_mappings: List[RouteMapping] = field(default_factory=list)
+ custom_names: Dict[str, str] = field(default_factory=dict) # operation_id -> custom_name
+ global_tags: List[str] = field(default_factory=list)
+ auto_sync: bool = False # Whether to auto-sync API changes
+
+class OpenAPIAnalyzer:
+ """OpenAPI Specification Analyzer"""
+
+ def __init__(self):
+ self._spec_cache: Dict[str, Dict[str, Any]] = {}
+
+ async def fetch_spec(self, spec_url: str) -> Dict[str, Any]:
+ """Get OpenAPI specification"""
+ if spec_url in self._spec_cache:
+ return self._spec_cache[spec_url]
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(spec_url)
+ response.raise_for_status()
+ spec = response.json()
+ self._spec_cache[spec_url] = spec
+ logger.info(f"Fetched OpenAPI spec from {spec_url}")
+ return spec
+ except Exception as e:
+ logger.error(f"Failed to fetch OpenAPI spec from {spec_url}: {e}")
+ raise
+
+ def analyze_endpoints(self, spec: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Analyze API endpoints"""
+ endpoints = []
+ paths = spec.get("paths", {})
+
+ for path, path_item in paths.items():
+ for method, operation in path_item.items():
+ if method.upper() not in [m.value for m in HTTPMethod]:
+ continue
+
+ endpoint_info = {
+ "path": path,
+ "method": method.upper(),
+ "operation_id": operation.get("operationId"),
+ "summary": operation.get("summary"),
+ "description": operation.get("description"),
+ "tags": operation.get("tags", []),
+ "parameters": operation.get("parameters", []),
+ "request_body": operation.get("requestBody"),
+ "responses": operation.get("responses", {}),
+ "security": operation.get("security", [])
+ }
+ endpoints.append(endpoint_info)
+
+ return endpoints
+
+ def suggest_mcp_type(self, endpoint: Dict[str, Any]) -> MCPComponentType:
+ """Suggest MCP component type"""
+ method = endpoint["method"]
+ path = endpoint["path"]
+
+ # GET requests usually map to Resource
+ if method == "GET":
+ # If path contains parameters, map to ResourceTemplate
+ if "{" in path and "}" in path:
+ return MCPComponentType.RESOURCE_TEMPLATE
+ else:
+ return MCPComponentType.RESOURCE
+
+ # Other methods map to Tool
+ return MCPComponentType.TOOL
+
+ def generate_component_name(self, endpoint: Dict[str, Any], custom_names: Dict[str, str] = None) -> str:
+ """Generate MCP component name"""
+ if custom_names and endpoint.get("operation_id") in custom_names:
+ return custom_names[endpoint["operation_id"]]
+
+ # Prioritize using operationId
+ operation_id = endpoint.get("operation_id")
+ if operation_id:
+ name = operation_id
+ else:
+ # Otherwise use method + path combination
+ method = endpoint.get("method", "GET").lower()
+ path = endpoint.get("path", "/")
+ name = f"{method}_{path.strip('/').replace('/', '_')}"
+
+ # Clean up invalid characters
+ name = re.sub(r"[^a-zA-Z0-9_]+", "_", name)
+ name = re.sub(r"_+", "_", name).strip("_")
+
+ return name
+
+class RouteMapper:
+ """Route Mapper"""
+
+ def apply_mappings(self, endpoint: Dict[str, Any], mappings: List[RouteMapping] = None) -> Tuple[MCPComponentType, List[str]]:
+ """Apply route mappings, return (MCP component type, tag list)"""
+ if not mappings:
+ # No mapping configured, use default suggestion
+ return OpenAPIAnalyzer().suggest_mcp_type(endpoint), endpoint.get("tags", [])
+
+ for mapping in mappings:
+ if self._match_endpoint(endpoint, mapping):
+ return mapping.mcp_type, mapping.tags + endpoint.get("tags", [])
+
+ return OpenAPIAnalyzer().suggest_mcp_type(endpoint), endpoint.get("tags", [])
+
+ def _match_endpoint(self, endpoint: Dict[str, Any], mapping: RouteMapping) -> bool:
+ """Determine if endpoint matches mapping rule"""
+ path_match = re.match(mapping.path_pattern, endpoint["path"]) is not None
+ method_match = (mapping.method is None) or (endpoint["method"] == mapping.method.value)
+ return path_match and method_match
+
+class OpenAPIIntegrationManager:
+ """OpenAPI Integration Manager"""
+
+ def __init__(self):
+ self.analyzer = OpenAPIAnalyzer()
+ self.route_mapper = RouteMapper()
+ self._services: Dict[str, OpenAPIServiceConfig] = {}
+
+ def register_openapi_service(self, config: OpenAPIServiceConfig):
+ """Register OpenAPI service"""
+ self._services[config.name] = config
+ logger.info(f"Registered OpenAPI service: {config.name}")
+
+ async def import_openapi_service(
+ self,
+ name: str,
+ spec_url: str,
+ base_url: Optional[str] = None,
+ route_mappings: List[RouteMapping] = None,
+ custom_names: Dict[str, str] = None
+ ) -> Dict[str, Any]:
+ """Import OpenAPI service"""
+
+ # Get specification
+ spec = await self.analyzer.fetch_spec(spec_url)
+
+ # Analyze endpoints
+ endpoints = self.analyzer.analyze_endpoints(spec)
+
+ # Generate MCP components
+ components = []
+ for endpoint in endpoints:
+ # Apply route mappings
+ mcp_type, tags = self.route_mapper.apply_mappings(endpoint, route_mappings)
+
+ # Generate component name
+ component_name = self.analyzer.generate_component_name(endpoint, custom_names)
+
+ component = {
+ "name": component_name,
+ "type": mcp_type.value,
+ "endpoint": endpoint,
+ "tags": tags + (endpoint.get("tags", [])),
+ "description": endpoint.get("description") or endpoint.get("summary"),
+ "service_name": name
+ }
+ components.append(component)
+
+ # Create service configuration
+ service_config = OpenAPIServiceConfig(
+ name=name,
+ spec_url=spec_url,
+ base_url=base_url or self._extract_base_url(spec),
+ route_mappings=route_mappings or [],
+ custom_names=custom_names or {}
+ )
+ self.register_openapi_service(service_config)
+
+ result = {
+ "service_name": name,
+ "spec_info": {
+ "title": spec.get("info", {}).get("title"),
+ "version": spec.get("info", {}).get("version"),
+ "description": spec.get("info", {}).get("description")
+ },
+ "components": components,
+ "total_endpoints": len(endpoints),
+ "component_types": {
+ "tools": len([c for c in components if c["type"] == "tool"]),
+ "resources": len([c for c in components if c["type"] == "resource"]),
+ "resource_templates": len([c for c in components if c["type"] == "resource_template"])
+ }
+ }
+
+ logger.info(f"Imported OpenAPI service {name}: {len(components)} components generated")
+ return result
+
+ async def sync_service_changes(self, service_name: str) -> Dict[str, Any]:
+ """Sync service changes"""
+ if service_name not in self._services:
+ raise ValueError(f"Service {service_name} not found")
+
+ config = self._services[service_name]
+
+ # Re-fetch specification
+ new_spec = await self.analyzer.fetch_spec(config.spec_url)
+ new_endpoints = self.analyzer.analyze_endpoints(new_spec)
+
+ # Compare changes (simplified)
+ return {
+ "service_name": service_name,
+ "changes_detected": True,
+ "new_endpoints_count": len(new_endpoints)
+ }
+
+ def _extract_base_url(self, spec: Dict[str, Any]) -> str:
+ """Extract base URL from OpenAPI specification"""
+ servers = spec.get("servers", [])
+ if servers and isinstance(servers, list) and servers[0].get("url"):
+ return servers[0]["url"]
+ return ""
+
+# Global instance
+_global_openapi_manager = None
+
+def get_openapi_manager() -> OpenAPIIntegrationManager:
+ """Get global OpenAPI integration manager"""
+ global _global_openapi_manager
+ if _global_openapi_manager is None:
+ _global_openapi_manager = OpenAPIIntegrationManager()
+ return _global_openapi_manager
+
diff --git a/src/mcpstore/core/transport.py b/src/mcpstore/core/integration/transport.py
similarity index 75%
rename from src/mcpstore/core/transport.py
rename to src/mcpstore/core/integration/transport.py
index 2fc31e99..97016e6b 100644
--- a/src/mcpstore/core/transport.py
+++ b/src/mcpstore/core/integration/transport.py
@@ -1,19 +1,20 @@
-import os, sys
+import os
+import sys
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dataclasses import dataclass
-from typing import Dict, Any, Optional, AsyncGenerator, List
+from typing import Dict, Any, Optional, AsyncGenerator
import uuid
import httpx
import json
import logging
-import asyncio
from urllib.parse import urljoin
logger = logging.getLogger(__name__)
@dataclass
class StreamableHTTPConfig:
- """Streamable HTTP传输配置"""
+ """Streamable HTTP transport configuration"""
base_url: str
timeout: int = 30
session_id: Optional[str] = None
@@ -23,19 +24,19 @@ class StreamableHTTPConfig:
session_id_header: str = "Mcp-Session-Id"
class StreamableHTTPTransport:
- """实现MCP Streamable HTTP传输协议
-
- 基于MCP 2025-03-26版本规范,提供统一的双向通信能力。
- 支持会话管理、连接恢复和向后兼容。
+ """Implements MCP Streamable HTTP transport protocol
+
+ Based on MCP 2025-03-26 version specification, providing unified bidirectional communication capabilities.
+ Supports session management, connection recovery and backward compatibility.
"""
- # 方法名映射,将简化名称映射到服务器期望的格式
+ # Method name mapping, mapping simplified names to server-expected format
METHOD_MAPPING = {
"list_tools": "tools/list",
"call_tool": "tools/call",
"initialize": "initialize",
"ping": "ping"
- # 可以根据需要添加更多映射
+ # More mappings can be added as needed
}
def __init__(self, config: StreamableHTTPConfig):
@@ -44,12 +45,12 @@ def __init__(self, config: StreamableHTTPConfig):
self.last_event_id: Optional[str] = None
async def initialize(self) -> Dict[str, Any]:
- """初始化连接并获取会话ID
-
- 发送初始化请求,建立会话,并返回服务器响应。
+ """Initialize connection and get session ID
+
+ Send initialization request, establish session, and return server response.
Returns:
- Dict[str, Any]: 服务器的初始化响应
+ Dict[str, Any]: Server initialization response
"""
headers = {
"Accept": "application/json, text/event-stream",
@@ -57,23 +58,21 @@ async def initialize(self) -> Dict[str, Any]:
}
request_id = str(uuid.uuid4())
- # 确保使用正确的方法名(initialize 不需要映射,但为了一致性,我们仍然从映射中获取)
+ # Ensure using correct method name (initialize doesn't need mapping, but for consistency we still get from mapping)
method = "initialize"
server_method = self.METHOD_MAPPING.get(method, method)
payload = {
- "jsonrpc": "2.0",
- "method": server_method,
+ "jsonrpc": "2.0",
+ "method": server_method,
"params": {
"clientInfo": {
"name": "mcp-client",
"version": "1.0.0"
},
- "protocolVersion": "2025-03-26", # 添加协议版本
- "capabilities": { # 添加客户端能力
- "streaming": True,
- "json": True,
- "binary": False
+ "protocolVersion": "2024-11-05", # Fixed: Use standard MCP protocol version
+ "capabilities": { # Fixed: Use standard MCP capability format
+ "tools": {}
}
},
"id": request_id
@@ -88,23 +87,23 @@ async def initialize(self) -> Dict[str, Any]:
)
response.raise_for_status()
- # 获取并保存会话ID
+ # Get and save session ID
session_id = response.headers.get(self.config.session_id_header)
if session_id:
self.config.session_id = session_id
- logger.info(f"Session established with ID: {session_id}")
+ logger.debug(f"Session established with ID: {session_id}")
- # 处理响应内容
+ # Handle response content
if response.content:
try:
return response.json()
except json.JSONDecodeError:
logger.warning(f"Failed to parse response as JSON: {response.content}")
- # 返回一个默认的成功响应,避免中断流程
+ # Return a default success response to avoid interrupting the process
return {"status": "connected", "session_id": session_id or "unknown"}
else:
logger.warning("Empty response received from server")
- # 返回一个默认的成功响应,避免中断流程
+ # Return a default success response to avoid interrupting the process
return {"status": "connected", "session_id": session_id or "unknown"}
except httpx.HTTPStatusError as e:
@@ -115,30 +114,30 @@ async def initialize(self) -> Dict[str, Any]:
raise
async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
- """调用工具方法
-
- 使用Streamable HTTP协议调用指定的工具,并返回结果。
- 此方法与registry.py中定义的SessionProtocol接口兼容。
-
+ """Call tool method
+
+ Use Streamable HTTP protocol to call specified tool and return result.
+ This method is compatible with SessionProtocol interface defined in registry.py.
+
Args:
- tool_name: 工具名称
- tool_args: 工具参数
-
+ tool_name: Tool name
+ tool_args: Tool arguments
+
Returns:
- Any: 工具执行结果
+ Any: Tool execution result
"""
- logger.info(f"Calling tool '{tool_name}' with args: {tool_args}")
+ logger.debug(f"Calling tool '{tool_name}' with args: {type(tool_args).__name__}")
try:
- # 发送工具调用请求
+ # Send tool call request
responses = []
- # 使用 call_tool 作为方法名,会被映射到 tools/call
+ # Use call_tool as method name, will be mapped to tools/call
method = "call_tool"
params = {"name": tool_name, "arguments": tool_args}
async for response in self.send_request(method, params):
responses.append(response)
- # 只获取第一个响应
+ # Only get first response
break
if not responses:
@@ -147,16 +146,16 @@ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
result = responses[0]
- # 格式化响应为兼容格式
+ # Format response for compatibility
if isinstance(result, dict) and "result" in result:
- # 如果响应中有result字段,将其作为文本内容返回
+ # If response has result field, return it as text content
return {"content": [{"text": str(result["result"])}]}
elif isinstance(result, dict) and "error" in result:
- # 如果响应中有error字段,将其作为错误信息返回
+ # If response has error field, return it as error message
error_msg = result.get("error", {}).get("message", "Unknown error")
return {"content": [{"text": f"Error: {error_msg}"}]}
else:
- # 其他情况,直接返回响应
+ # Other cases, return response directly
return {"content": [{"text": str(result)}]}
except Exception as e:
@@ -164,14 +163,14 @@ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
return {"content": [{"text": f"Error calling tool '{tool_name}': {str(e)}"}]}
async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]:
- """发送请求并处理流式响应
-
+ """Send request and handle streaming response
+
Args:
- method: 请求方法名
- params: 请求参数
-
+ method: Request method name
+ params: Request parameters
+
Yields:
- Dict[str, Any]: 服务器响应数据流
+ Dict[str, Any]: Server response data stream
"""
headers = {
"Accept": "application/json, text/event-stream",
@@ -184,7 +183,7 @@ async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenera
if self.last_event_id:
headers[self.config.event_id_header] = self.last_event_id
- # 将简化的方法名转换为服务器期望的格式
+ # Convert simplified method name to server-expected format
server_method = self.METHOD_MAPPING.get(method, method)
if server_method != method:
logger.debug(f"Mapping method name from '{method}' to '{server_method}'")
@@ -210,7 +209,7 @@ async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenera
content_type = response.headers.get("Content-Type", "")
if "text/event-stream" in content_type:
- # 处理SSE流
+ # Handle SSE stream
buffer = ""
async for chunk in response.aiter_text():
buffer += chunk
@@ -221,11 +220,11 @@ async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenera
for line in message.split("\n"):
if not line or line.startswith(":"):
- continue # 忽略注释和空行
+ continue # Ignore comments and empty lines
if ":" in line:
field, value = line.split(":", 1)
- value = value.lstrip() # 移除前导空格
+ value = value.lstrip() # Remove leading spaces
if field == "id":
self.last_event_id = value
@@ -234,13 +233,13 @@ async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenera
event_data = json.loads(value)
except json.JSONDecodeError:
logger.warning(f"Failed to parse SSE data: {value}")
-
+
if event_data:
yield event_data
else:
- # 处理普通JSON响应 - 修复方法,读取完整响应内容
+ # Handle regular JSON response - Fixed method, read complete response content
try:
- # 读取完整响应内容而不是直接调用response.json()
+ # Read complete response content instead of calling response.json() directly
content = await response.aread()
data = json.loads(content)
yield data
@@ -256,11 +255,11 @@ async def send_request(self, method: str, params: Dict[str, Any]) -> AsyncGenera
raise
async def send_notification(self, method: str, params: Dict[str, Any]) -> None:
- """发送通知(不需要响应的请求)
-
+ """Send notification (request that doesn't need response)
+
Args:
- method: 通知方法名
- params: 通知参数
+ method: Notification method name
+ params: Notification parameters
"""
headers = {
"Accept": "application/json",
@@ -291,12 +290,12 @@ async def send_notification(self, method: str, params: Dict[str, Any]) -> None:
raise
async def listen_server(self) -> AsyncGenerator[Dict[str, Any], None]:
- """监听服务器发送的消息
-
- 打开GET连接以接收服务器主动发送的消息。
-
+ """Listen for messages sent by server
+
+ Open GET connection to receive messages actively sent by server.
+
Yields:
- Dict[str, Any]: 服务器发送的消息
+ Dict[str, Any]: Messages sent by server
"""
headers = {
"Accept": "text/event-stream"
@@ -330,11 +329,11 @@ async def listen_server(self) -> AsyncGenerator[Dict[str, Any], None]:
for line in message.split("\n"):
if not line or line.startswith(":"):
- continue # 忽略注释和空行
+ continue # Ignore comments and empty lines
if ":" in line:
field, value = line.split(":", 1)
- value = value.lstrip() # 移除前导空格
+ value = value.lstrip() # Remove leading spaces
if field == "id":
self.last_event_id = value
@@ -343,7 +342,7 @@ async def listen_server(self) -> AsyncGenerator[Dict[str, Any], None]:
event_data = json.loads(value)
except json.JSONDecodeError:
logger.warning(f"Failed to parse SSE data: {value}")
-
+
if event_data:
yield event_data
@@ -358,9 +357,9 @@ async def listen_server(self) -> AsyncGenerator[Dict[str, Any], None]:
raise
async def close(self) -> None:
- """关闭连接并清理资源
-
- 如果有会话ID,尝试显式终止会话。
+ """Close connection and cleanup resources
+
+ If session ID exists, try to explicitly terminate session.
"""
if self.config.session_id:
try:
@@ -369,10 +368,10 @@ async def close(self) -> None:
urljoin(self.config.base_url, "/mcp"),
headers=headers
)
- logger.info(f"Session {self.config.session_id} terminated")
+ logger.debug(f"Session {self.config.session_id} terminated")
except Exception as e:
logger.warning(f"Failed to terminate session: {e}")
await self.client.aclose()
- logger.info("Transport resources cleaned up")
-
\ No newline at end of file
+ logger.debug("Transport resources cleaned up")
+
diff --git a/src/mcpstore/core/lifecycle/__init__.py b/src/mcpstore/core/lifecycle/__init__.py
new file mode 100644
index 00000000..489eb60d
--- /dev/null
+++ b/src/mcpstore/core/lifecycle/__init__.py
@@ -0,0 +1,22 @@
+"""
+MCPStore Lifecycle Management Module
+Lifecycle management module
+
+Responsible for service lifecycle, health monitoring, content management and intelligent reconnection
+"""
+
+from .config import ServiceLifecycleConfig
+from .content_manager import ServiceContentManager
+
+# Event-driven architecture unified export: only keep core components
+__all__ = [
+ 'ServiceContentManager',
+ 'ServiceLifecycleConfig',
+]
+
+# 导出常用类型
+try:
+ from mcpstore.core.models.service import ServiceConnectionState, ServiceStateMetadata
+ __all__.extend(['ServiceConnectionState', 'ServiceStateMetadata'])
+except ImportError:
+ pass
diff --git a/src/mcpstore/core/lifecycle/config.py b/src/mcpstore/core/lifecycle/config.py
new file mode 100644
index 00000000..246bf341
--- /dev/null
+++ b/src/mcpstore/core/lifecycle/config.py
@@ -0,0 +1,40 @@
+"""
+Service Lifecycle Configuration
+"""
+
+from dataclasses import dataclass
+
+from mcpstore.config.config_defaults import (
+ HealthCheckConfigDefaults,
+ ServiceLifecycleConfigDefaults,
+)
+
+_health_defaults = HealthCheckConfigDefaults()
+_service_defaults = ServiceLifecycleConfigDefaults()
+
+
+@dataclass
+class ServiceLifecycleConfig:
+ """Service lifecycle configuration (single source of truth)"""
+ # State transition thresholds (failure count)
+ warning_failure_threshold: int = _health_defaults.warning_failure_threshold # First failure in HEALTHY enters WARNING
+ reconnecting_failure_threshold: int = _health_defaults.reconnecting_failure_threshold # Two consecutive failures in WARNING enter RECONNECTING
+ max_reconnect_attempts: int = _health_defaults.max_reconnect_attempts # Maximum reconnection attempts
+
+ # Reconnection backoff
+ base_reconnect_delay: float = _health_defaults.base_reconnect_delay # Base reconnection delay (seconds)
+ max_reconnect_delay: float = _health_defaults.max_reconnect_delay # Maximum reconnection delay (seconds)
+ long_retry_interval: float = _health_defaults.long_retry_interval # Long retry interval (seconds)
+
+ # Health check (period/threshold/timeout)
+ normal_heartbeat_interval: float = _health_defaults.normal_heartbeat_interval # Normal heartbeat interval (seconds)
+ warning_heartbeat_interval: float = _health_defaults.warning_heartbeat_interval # Warning state heartbeat interval (seconds)
+ health_check_ping_timeout: float = _health_defaults.health_check_ping_timeout # Health check ping timeout (seconds)
+ warning_ping_timeout: float = _health_defaults.warning_ping_timeout # Warning/Reconnecting 状态下的宽松超时
+ ping_timeout_http: float = _health_defaults.ping_timeout_http # HTTP 传输默认 ping 超时
+ ping_timeout_sse: float = _health_defaults.ping_timeout_sse # SSE 传输默认 ping 超时
+ ping_timeout_stdio: float = _health_defaults.ping_timeout_stdio # STDIO/Studio 传输默认 ping 超时
+
+ # Timeout configuration
+ initialization_timeout: float = _service_defaults.initialization_timeout # Initialization timeout (seconds)
+ disconnection_timeout: float = _health_defaults.disconnection_timeout # Disconnection timeout (seconds)
diff --git a/src/mcpstore/core/lifecycle/content_manager.py b/src/mcpstore/core/lifecycle/content_manager.py
new file mode 100644
index 00000000..ab30d908
--- /dev/null
+++ b/src/mcpstore/core/lifecycle/content_manager.py
@@ -0,0 +1,407 @@
+"""
+Service Content Manager - Periodically updates tools, resources and prompts
+Responsible for monitoring and updating all service content, ensuring cache stays synchronized with actual services
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict, Set, Optional, List, Any, Tuple
+
+from fastmcp import Client
+
+from mcpstore.config.config_dataclasses import ContentUpdateConfig
+from mcpstore.core.configuration.config_processor import ConfigProcessor
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ServiceContentSnapshot:
+ """Service content snapshot"""
+ service_name: str
+ agent_id: str
+ tools_count: int
+ tools_hash: str # Hash value of tool list for fast comparison
+ resources_count: int = 0 # Reserved: resource count
+ resources_hash: str = "" # Reserved: resource hash
+ prompts_count: int = 0 # Reserved: prompt count
+ prompts_hash: str = "" # Reserved: prompt hash
+ last_updated: datetime = None
+
+ def __post_init__(self):
+ if self.last_updated is None:
+ self.last_updated = datetime.now()
+
+
+# ContentUpdateConfig is now imported from mcpstore.config.toml_config
+
+
+class ServiceContentManager:
+ """服务内容管理器"""
+
+ def __init__(self, orchestrator):
+ self.orchestrator = orchestrator
+ self.registry = orchestrator.registry
+ self.lifecycle_manager = orchestrator.lifecycle_manager
+
+ # 使用 MCPStoreConfig 获取内容更新配置 - 延迟导入避免循环依赖
+ try:
+ from mcpstore.config.toml_config import get_content_update_config_with_defaults
+ self.config = get_content_update_config_with_defaults()
+ except Exception as e:
+ logger.warning(f"Failed to get content update config, using defaults: {e}")
+ self.config = ContentUpdateConfig()
+ logger.debug(f"ContentManager initialized with config from MCPStoreConfig: tools_update_interval={self.config.tools_update_interval}s")
+
+ # 事件总线(可选)
+ self.event_bus = None
+ try:
+ self.event_bus = getattr(getattr(orchestrator, 'store', None), 'container', None).event_bus # type: ignore
+ except Exception:
+ self.event_bus = None
+
+ # 内容快照缓存:agent_id -> service_name -> snapshot
+ self.content_snapshots: Dict[str, Dict[str, ServiceContentSnapshot]] = {}
+
+ # 更新队列和状态
+ self.update_queue: Set[Tuple[str, str]] = set() # (agent_id, service_name)
+ self.updating_services: Set[Tuple[str, str]] = set() # 正在更新的服务
+
+ # 失败统计:(agent_id, service_name) -> consecutive_failures
+ self.failure_counts: Dict[Tuple[str, str], int] = {}
+
+ # 事件驱动处理任务
+ self._process_task: Optional[asyncio.Task] = None
+
+ # 订阅事件:仅在 HEALTHY/WARNING 触发更新
+ try:
+ if self.event_bus is not None:
+ from mcpstore.core.events.service_events import ServiceStateChanged
+ async def _on_state_changed(event: 'ServiceStateChanged'):
+ try:
+ if event.new_state in ("healthy", "warning"):
+ self.update_queue.add((event.agent_id, event.service_name))
+ self._schedule_queue_processing()
+ elif event.new_state in ("disconnected", "disconnecting", "unreachable"):
+ # 终止/不可达时清理队列,避免无效更新
+ self.update_queue.discard((event.agent_id, event.service_name))
+ self.updating_services.discard((event.agent_id, event.service_name))
+ except Exception as e:
+ logger.debug(f"ContentManager state-change handler error: {e}")
+ self.event_bus.subscribe(ServiceStateChanged, _on_state_changed, priority=10)
+ except Exception as e:
+ logger.debug(f"EventBus subscription skipped: {e}")
+
+ logger.debug("ServiceContentManager initialized")
+
+ def _schedule_queue_processing(self):
+ """调度一次队列处理(去抖:避免重复并发)"""
+ try:
+ if self._process_task is None or self._process_task.done():
+ self._process_task = asyncio.create_task(self._drain_queue())
+ except Exception as e:
+ logger.debug(f"Failed to schedule queue processing: {e}")
+
+ async def _drain_queue(self):
+ """持续处理队列直到清空(避免阻塞事件总线)"""
+ try:
+ # 循环直到队列清空
+ while self.update_queue:
+ await self._process_content_updates()
+ await asyncio.sleep(0)
+ except Exception as e:
+ logger.debug(f"Drain queue error: {e}")
+
+ async def start(self):
+ """启动内容管理器(事件驱动,无主循环)"""
+ logger.debug("ServiceContentManager started (event-driven mode; no loop)")
+
+ async def stop(self):
+ """停止内容管理器(事件驱动,无主循环)"""
+ logger.debug("ServiceContentManager stopped (event-driven mode; no loop)")
+
+ def add_service_for_monitoring(self, agent_id: str, service_name: str):
+ """添加服务到内容监控"""
+ if agent_id not in self.content_snapshots:
+ self.content_snapshots[agent_id] = {}
+
+ # 创建初始快照(工具数量为0,等待首次更新)
+ self.content_snapshots[agent_id][service_name] = ServiceContentSnapshot(
+ service_name=service_name,
+ agent_id=agent_id,
+ tools_count=0,
+ tools_hash="",
+ last_updated=datetime.now()
+ )
+
+ # 添加到更新队列
+ self.update_queue.add((agent_id, service_name))
+ self._schedule_queue_processing()
+ logger.debug(f"Added service {service_name} to content monitoring (agent_id={agent_id})")
+
+ def remove_service_from_monitoring(self, agent_id: str, service_name: str):
+ """从内容监控中移除服务"""
+ if agent_id in self.content_snapshots:
+ self.content_snapshots[agent_id].pop(service_name, None)
+ if not self.content_snapshots[agent_id]:
+ del self.content_snapshots[agent_id]
+
+ self.update_queue.discard((agent_id, service_name))
+ self.updating_services.discard((agent_id, service_name))
+ self.failure_counts.pop((agent_id, service_name), None)
+
+ logger.info(f"Removed service {service_name} from content monitoring (agent_id={agent_id})")
+
+ async def force_update_service_content(self, agent_id: str, service_name: str) -> bool:
+ """Force update content of specified service"""
+ try:
+ return await self._update_service_content(agent_id, service_name)
+ except Exception as e:
+ logger.error(f"Failed to force update content for {service_name}: {e}")
+ return False
+
+ def get_service_snapshot(self, agent_id: str, service_name: str) -> Optional[ServiceContentSnapshot]:
+ """Get service content snapshot"""
+ return self.content_snapshots.get(agent_id, {}).get(service_name)
+
+ async def _content_update_loop(self):
+ """Content update main loop"""
+ consecutive_failures = 0
+ max_consecutive_failures = 5
+
+ while self.is_running:
+ try:
+ await asyncio.sleep(30) # Check every 30 seconds
+ await self._process_content_updates()
+ consecutive_failures = 0
+
+ except asyncio.CancelledError:
+ logger.info("Content update loop cancelled")
+ break
+ except Exception as e:
+ consecutive_failures += 1
+ logger.error(f"Content update loop error (failure {consecutive_failures}/{max_consecutive_failures}): {e}")
+
+ if consecutive_failures >= max_consecutive_failures:
+ logger.critical("Too many consecutive content update failures, stopping loop")
+ break
+
+ # Exponential backoff delay
+ backoff_delay = min(60 * (2 ** consecutive_failures), 300) # Max 5 minutes
+ await asyncio.sleep(backoff_delay)
+
+ async def _process_content_updates(self):
+ """Process content update queue"""
+ if not self.update_queue:
+ # Event-driven: return directly if no pending tasks
+ return
+
+ # Limit concurrent update count
+ available_slots = self.config.max_concurrent_updates - len(self.updating_services)
+ if available_slots <= 0:
+ return
+
+ # Get services to be updated
+ services_to_update = list(self.update_queue)[:available_slots]
+
+ # Concurrent updates
+ update_tasks = []
+ for agent_id, service_name in services_to_update:
+ self.update_queue.discard((agent_id, service_name))
+ self.updating_services.add((agent_id, service_name))
+
+ task = asyncio.create_task(
+ self._update_service_content_with_cleanup(agent_id, service_name)
+ )
+ update_tasks.append(task)
+
+ if update_tasks:
+ await asyncio.gather(*update_tasks, return_exceptions=True)
+
+
+ async def _get_service_config_from_pykv_async(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """
+ 从 pykv 获取服务配置
+
+ 遵循 "pykv 唯一真相数据源" 原则,从 ServiceEntity 中读取配置。
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务配置字典,如果不存在返回 None
+ """
+ try:
+ # 生成服务全局名称
+ from mcpstore.core.cache.naming_service import NamingService
+ global_name = NamingService.generate_service_global_name(service_name, agent_id)
+
+ # 从 pykv 获取服务实体
+ # 使用 ServiceRegistry 的 _cache_service_manager(ServiceEntityManager)
+ service_entity_manager = self.registry._cache_service_manager
+ service_entity = await service_entity_manager.get_service(global_name)
+
+ if service_entity is None:
+ logger.debug(f"Service entity not found in pykv: {global_name}")
+ return None
+
+ # 返回服务配置(ServiceEntity 是 dataclass,直接访问 config 属性)
+ config = service_entity.config
+ if not config:
+ logger.debug(f"Service entity config is empty: {global_name}")
+ return None
+
+ logger.debug(f"Successfully retrieved service config from pykv: {global_name}")
+ return config
+
+ except Exception as e:
+ logger.error(f"Failed to get service config from pykv: agent_id={agent_id}, service_name={service_name}, error={e}")
+ raise
+
+ async def _update_service_content_with_cleanup(self, agent_id: str, service_name: str):
+ """带清理的服务内容更新"""
+ try:
+ success = await self._update_service_content(agent_id, service_name)
+ if success:
+ # 重置失败计数
+ self.failure_counts.pop((agent_id, service_name), None)
+ else:
+ # 增加失败计数
+ key = (agent_id, service_name)
+ self.failure_counts[key] = self.failure_counts.get(key, 0) + 1
+ finally:
+ self.updating_services.discard((agent_id, service_name))
+
+ async def _update_service_content(self, agent_id: str, service_name: str) -> bool:
+ """更新服务内容(工具、资源、提示词)"""
+ try:
+ # 从 pykv 获取服务配置(遵循 pykv 唯一真相数据源原则)
+ service_config = await self._get_service_config_from_pykv_async(agent_id, service_name)
+ if not service_config:
+ logger.warning(f"Service config not found in pykv: agent_id={agent_id}, service_name={service_name}")
+ return False
+
+ # 创建临时客户端
+ user_config = {"mcpServers": {service_name: service_config}}
+ fastmcp_config = ConfigProcessor.process_user_config_for_fastmcp(user_config)
+
+ if service_name not in fastmcp_config.get("mcpServers", {}):
+ logger.warning(f"Service {service_name} not found in processed config")
+ return False
+
+ client = Client(fastmcp_config)
+
+ async with asyncio.timeout(self.config.update_timeout):
+ async with client:
+ # 获取工具列表
+ tools = await client.list_tools()
+ tools_count = len(tools)
+ tools_hash = self._calculate_tools_hash(tools)
+
+ # 检查是否有变化
+ current_snapshot = self.get_service_snapshot(agent_id, service_name)
+ if current_snapshot and current_snapshot.tools_hash == tools_hash:
+ # 没有变化,只更新时间戳
+ current_snapshot.last_updated = datetime.now()
+ logger.debug(f"No content changes detected for {service_name}")
+ return True
+
+ # 有变化,更新缓存
+ await self._update_service_tools_cache(agent_id, service_name, tools)
+
+ # 更新快照
+ new_snapshot = ServiceContentSnapshot(
+ service_name=service_name,
+ agent_id=agent_id,
+ tools_count=tools_count,
+ tools_hash=tools_hash,
+ last_updated=datetime.now()
+ )
+
+ if agent_id not in self.content_snapshots:
+ self.content_snapshots[agent_id] = {}
+ self.content_snapshots[agent_id][service_name] = new_snapshot
+
+ logger.info(f"Updated content for {service_name}: {tools_count} tools")
+ return True
+
+ except asyncio.TimeoutError:
+ logger.warning(f"Content update timeout for {service_name}")
+ return False
+ except Exception as e:
+ logger.error(f"Failed to update content for {service_name}: {e}")
+ return False
+
+ def _calculate_tools_hash(self, tools: List[Any]) -> str:
+ """计算工具列表的哈希值"""
+ import hashlib
+
+ # 提取关键信息用于哈希计算
+ tool_signatures = []
+ for tool in tools:
+ # 兼容字典和对象两种格式
+ if hasattr(tool, 'get'):
+ # 字典格式
+ name = tool.get('name', '')
+ description = tool.get('description', '')
+ else:
+ # 对象格式(如FastMCP的Tool对象)
+ name = getattr(tool, 'name', '')
+ description = getattr(tool, 'description', '')
+
+ signature = f"{name}:{description}"
+ tool_signatures.append(signature)
+
+ # 排序确保一致性
+ tool_signatures.sort()
+ content = "|".join(tool_signatures)
+
+ return hashlib.md5(content.encode()).hexdigest()
+
+ async def _update_service_tools_cache(self, agent_id: str, service_name: str, tools: List[Any]):
+ """更新服务工具缓存"""
+ # 获取服务会话
+ service_session = self.registry.get_session(agent_id, service_name)
+ if not service_session:
+ logger.warning(f"No session found for service {service_name}")
+ return
+
+ # 统一通过 Registry API 更新工具缓存,避免直访内部字典
+ # - 先清理该服务的工具缓存
+ # - 再批量注册当前工具定义
+ processed_tools: List[Tuple[str, Dict[str, Any]]] = []
+ for tool in tools:
+ if hasattr(tool, 'get'):
+ tool_name = tool.get("name")
+ tool_dict = dict(tool)
+ else:
+ tool_name = getattr(tool, 'name', None)
+ tool_dict = {
+ 'name': getattr(tool, 'name', ''),
+ 'description': getattr(tool, 'description', ''),
+ 'inputSchema': getattr(tool, 'inputSchema', {})
+ }
+ if not tool_name:
+ continue
+ # 规范化为 function 形式,便于后续 full 模式与硬映射
+ if "function" not in tool_dict:
+ tool_def = {"type": "function", "function": tool_dict}
+ else:
+ tool_def = tool_dict
+ processed_tools.append((tool_name, tool_def))
+
+ # 加锁执行原子更新,使用异步版本避免事件循环冲突
+ locks_owner = getattr(self.orchestrator, 'store', None)
+ agent_locks = getattr(locks_owner, 'agent_locks', None) if locks_owner else None
+ if agent_locks:
+ async with agent_locks.write(agent_id):
+ self.registry.clear_service_tools_only(agent_id, service_name)
+ await self.registry.add_service_async(agent_id=agent_id, name=service_name, session=service_session, tools=processed_tools, preserve_mappings=True)
+ else:
+ self.registry.clear_service_tools_only(agent_id, service_name)
+ await self.registry.add_service_async(agent_id=agent_id, name=service_name, session=service_session, tools=processed_tools, preserve_mappings=True)
+
+ logger.debug(f"Updated tool cache for {service_name}: {len(processed_tools)} tools")
diff --git a/src/mcpstore/core/lifecycle/state_machine.py b/src/mcpstore/core/lifecycle/state_machine.py
new file mode 100644
index 00000000..28cffed3
--- /dev/null
+++ b/src/mcpstore/core/lifecycle/state_machine.py
@@ -0,0 +1,67 @@
+"""
+Service Lifecycle State Machine
+Responsible for handling service state transition logic
+"""
+
+import logging
+from datetime import datetime
+
+from mcpstore.core.models.service import ServiceConnectionState, ServiceStateMetadata
+from .config import ServiceLifecycleConfig
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceStateMachine:
+ """Service lifecycle state machine"""
+
+ def __init__(self, config: ServiceLifecycleConfig):
+ self.config = config
+
+ async def transition_to_state(self, agent_id: str, service_name: str,
+ new_state: ServiceConnectionState,
+ get_state_func, get_metadata_func,
+ set_state_func, on_state_entered_func):
+ """Execute state transition"""
+ old_state = get_state_func(agent_id, service_name)
+ logger.debug(f"[STATE_TRANSITION] attempting service='{service_name}' from={old_state} to={new_state}")
+
+ if old_state == new_state:
+ logger.debug(f"[STATE_TRANSITION] No change needed for {service_name}: already in {new_state}")
+ return
+
+ # Update state
+ logger.debug(f"[STATE_TRANSITION] updating service='{service_name}' from={old_state} to={new_state}")
+ set_state_func(agent_id, service_name, new_state)
+ metadata = get_metadata_func(agent_id, service_name)
+ if metadata:
+ metadata.state_entered_time = datetime.now()
+ logger.debug(f"[STATE_TRANSITION] updated_state_entered_time service='{service_name}'")
+ else:
+ logger.warning(f"[STATE_TRANSITION] no_metadata service='{service_name}' during_transition=True")
+
+ # Execute state entry handling
+ logger.debug(f"[STATE_TRANSITION] calling_on_state_entered service='{service_name}'")
+ await on_state_entered_func(agent_id, service_name, new_state, old_state)
+
+ logger.info(f"[STATE_TRANSITION] transitioned service='{service_name}' agent='{agent_id}' from={old_state} to={new_state}")
+
+ async def on_state_entered(self, agent_id: str, service_name: str,
+ new_state: ServiceConnectionState, old_state: ServiceConnectionState,
+ enter_reconnecting_func, enter_unreachable_func,
+ enter_disconnecting_func, enter_healthy_func):
+ """State entry handling logic"""
+ if new_state == ServiceConnectionState.RECONNECTING:
+ await enter_reconnecting_func(agent_id, service_name)
+ elif new_state == ServiceConnectionState.UNREACHABLE:
+ await enter_unreachable_func(agent_id, service_name)
+ elif new_state == ServiceConnectionState.DISCONNECTING:
+ await enter_disconnecting_func(agent_id, service_name)
+ elif new_state == ServiceConnectionState.HEALTHY:
+ await enter_healthy_func(agent_id, service_name)
+
+ def should_retry_now(self, metadata: ServiceStateMetadata) -> bool:
+ """Determine if should retry immediately"""
+ if not metadata.next_retry_time:
+ return True
+ return datetime.now() >= metadata.next_retry_time
diff --git a/src/mcpstore/core/logic/__init__.py b/src/mcpstore/core/logic/__init__.py
new file mode 100644
index 00000000..526ec23a
--- /dev/null
+++ b/src/mcpstore/core/logic/__init__.py
@@ -0,0 +1,10 @@
+"""
+逻辑核心模块
+
+包含所有纯同步的业务逻辑,不包含任何 IO 操作。
+遵循 Functional Core, Imperative Shell 架构原则。
+"""
+
+from .tool_logic import ToolLogicCore
+
+__all__ = ["ToolLogicCore"]
diff --git a/src/mcpstore/core/logic/tool_logic.py b/src/mcpstore/core/logic/tool_logic.py
new file mode 100644
index 00000000..d76614a6
--- /dev/null
+++ b/src/mcpstore/core/logic/tool_logic.py
@@ -0,0 +1,385 @@
+"""
+工具操作的纯逻辑核心
+
+严格约束:
+- 必须是纯同步函数
+- 不包含任何 IO 操作(no pykv, no file IO, no network IO)
+- 不调用任何异步方法
+- 不使用 await/asyncio.run()
+- 只做计算,不执行实际操作
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ToolInfo:
+ """工具信息(纯数据结构)"""
+ name: str # 工具全局名(L3)
+ tool_original_name: str # FastMCP 标准格式(L2)
+ description: str
+ service_name: str # 服务原始名(L0,保持与 service_original_name 一致)
+ service_original_name: str # 服务原始名(L0/FastMCP 视角)
+ service_global_name: str # 服务全局名(L3)
+ client_id: Optional[str]
+ inputSchema: Dict[str, Any]
+
+ @classmethod
+ def from_entity(
+ cls,
+ entity_data: Dict[str, Any],
+ service_original_name: str,
+ service_global_name: str,
+ client_id: Optional[str] = None
+ ) -> "ToolInfo":
+ """从实体数据创建 ToolInfo"""
+ if not entity_data.get("tool_original_name"):
+ raise ValueError("tool_original_name is missing, cannot build ToolInfo")
+ if not service_global_name:
+ raise ValueError("service_global_name is missing, cannot build ToolInfo")
+ return cls(
+ name=entity_data.get("tool_global_name", ""),
+ tool_original_name=entity_data.get("tool_original_name", ""),
+ description=entity_data.get("description", ""),
+ service_name=service_original_name,
+ service_original_name=service_original_name,
+ service_global_name=service_global_name,
+ client_id=client_id,
+ inputSchema=entity_data.get("input_schema", {})
+ )
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "name": self.name,
+ "description": self.description,
+ "service_name": self.service_name,
+ "service_original_name": self.service_original_name,
+ "service_global_name": self.service_global_name,
+ "tool_original_name": self.tool_original_name,
+ "client_id": self.client_id,
+ "inputSchema": self.inputSchema
+ }
+
+
+@dataclass
+class ToolStatusItem:
+ """工具状态项(纯数据结构)"""
+ tool_global_name: str
+ tool_original_name: str
+ status: str # "available" | "unavailable"
+
+
+@dataclass
+class ServiceStatus:
+ """服务状态(纯数据结构)"""
+ service_global_name: str
+ health_status: str
+ tools: List[ToolStatusItem]
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "ServiceStatus":
+ """从字典创建 ServiceStatus"""
+ tools = []
+ for tool_data in data.get("tools", []):
+ tools.append(ToolStatusItem(
+ tool_global_name=tool_data.get("tool_global_name", ""),
+ tool_original_name=tool_data.get("tool_original_name", ""),
+ status=tool_data.get("status", "unavailable")
+ ))
+
+ return cls(
+ service_global_name=data.get("service_global_name", ""),
+ health_status=data.get("health_status", "unknown"),
+ tools=tools
+ )
+
+
+class ToolLogicCore:
+ """
+ 工具操作的纯逻辑核心
+
+ 严格遵循 Functional Core 原则:
+ - 所有方法都是纯同步函数
+ - 不包含任何 IO 操作
+ - 只做计算和数据转换
+ - 遇到错误必须抛出,不做静默处理
+ """
+
+ @staticmethod
+ def extract_original_tool_name(
+ tool_name: str,
+ service_name: str,
+ alt_service_name: Optional[str] = None
+ ) -> str:
+ """
+ 提取工具的原始名称(去除服务前缀)
+ 优先使用提供的服务名/备用服务名做前缀匹配,匹配不上则原样返回。
+ """
+ for prefix in (service_name, alt_service_name):
+ if prefix:
+ full_prefix = f"{prefix}_"
+ if tool_name.startswith(full_prefix):
+ return tool_name[len(full_prefix):]
+ return tool_name
+
+ @staticmethod
+ def build_tools_from_entities(
+ tool_entities: List[Dict[str, Any]],
+ service_relations: List[Dict[str, Any]],
+ client_id_map: Dict[str, str]
+ ) -> List[ToolInfo]:
+ """
+ 从实体数据构建工具列表
+
+ 纯同步计算,无 IO。
+
+ Args:
+ tool_entities: 工具实体列表(从 pykv 实体层读取)
+ service_relations: 服务关系列表(从 pykv 关系层读取)
+ client_id_map: 服务名到 client_id 的映射
+
+ Returns:
+ ToolInfo 列表
+ """
+ # 构建服务全局名到原始名的映射
+ service_name_map: Dict[str, str] = {}
+ for rel in service_relations:
+ global_name = rel.get("service_global_name")
+ original_name = rel.get("service_original_name")
+ if global_name and original_name:
+ service_name_map[global_name] = original_name
+
+ tools = []
+ for entity in tool_entities:
+ if entity is None:
+ continue
+
+ service_global_name = entity.get("service_global_name", "")
+ service_original_name = service_name_map.get(
+ service_global_name,
+ entity.get("service_original_name", "")
+ )
+ client_id = client_id_map.get(service_global_name)
+
+ tool_info = ToolInfo.from_entity(
+ entity,
+ service_original_name,
+ service_global_name,
+ client_id
+ )
+ tools.append(tool_info)
+
+ return tools
+
+ @staticmethod
+ def filter_tools_by_service(
+ tools: List[ToolInfo],
+ service_name: Optional[str]
+ ) -> List[ToolInfo]:
+ """
+ 按服务名筛选工具
+
+ 纯同步计算,无 IO。
+
+ Args:
+ tools: 工具列表
+ service_name: 服务名称(None 表示不筛选)
+
+ Returns:
+ 筛选后的工具列表
+ """
+ if service_name is None:
+ return tools
+
+ return [t for t in tools if t.service_name == service_name]
+
+ @staticmethod
+ def filter_tools_by_availability(
+ tools: List[ToolInfo],
+ service_status_map: Dict[str, ServiceStatus]
+ ) -> List[ToolInfo]:
+ """
+ 按可用性筛选工具
+
+ 纯同步计算,无 IO。
+ 遇到错误必须抛出,不做静默处理。
+
+ Args:
+ tools: 工具列表
+ service_status_map: 服务状态映射(service_global_name -> ServiceStatus)
+
+ Returns:
+ 可用的工具列表
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具状态不存在
+ """
+ result = []
+
+ for tool in tools:
+ service_global_name = tool.service_global_name
+
+ # 获取服务状态
+ status = service_status_map.get(service_global_name)
+ if status is None:
+ raise RuntimeError(
+ f"Service state does not exist, cannot check tool availability: "
+ f"service_global_name={service_global_name}, tool={tool.name}"
+ )
+
+ original_tool_name = tool.tool_original_name or ToolLogicCore.extract_original_tool_name(
+ tool.name,
+ service_global_name,
+ tool.service_name
+ )
+
+ # 查找工具状态
+ tool_status = None
+ for ts in status.tools:
+ if ts.tool_original_name == original_tool_name:
+ tool_status = ts
+ break
+
+ if tool_status is None:
+ raise RuntimeError(
+ f"Tool does not exist in service state: "
+ f"service_global_name={service_global_name}, "
+ f"tool={tool.name}, original_name={original_tool_name}"
+ )
+
+ # 只返回可用的工具
+ if tool_status.status == "available":
+ result.append(tool)
+
+ return result
+
+ @staticmethod
+ def _extract_service_global_name(tool_global_name: str) -> str:
+ """
+ 从工具全局名称中提取服务全局名称
+
+ 工具全局名称格式:{service_global_name}_{tool_original_name}
+
+ Args:
+ tool_global_name: 工具全局名称
+
+ Returns:
+ 服务全局名称
+ """
+ # 找到最后一个下划线的位置
+ last_underscore = tool_global_name.rfind("_")
+ if last_underscore == -1:
+ return tool_global_name
+
+ return tool_global_name[:last_underscore]
+
+ @staticmethod
+ def map_to_agent_view(
+ tools: List[ToolInfo],
+ global_to_local_map: Dict[str, str]
+ ) -> List[ToolInfo]:
+ """
+ 将全局工具名映射到 Agent 本地视图
+
+ 纯同步计算,无 IO。
+
+ Args:
+ tools: 工具列表(全局名称)
+ global_to_local_map: 全局服务名到本地服务名的映射
+
+ Returns:
+ 本地视图的工具列表
+ """
+ result = []
+
+ for tool in tools:
+ service_global_name = tool.service_global_name
+ local_service_name = global_to_local_map.get(service_global_name)
+ if local_service_name is None:
+ # 没有映射,跳过
+ continue
+
+ # 创建本地视图的工具
+ local_tool_name = tool.name.replace(
+ f"{service_global_name}_",
+ f"{local_service_name}_",
+ 1
+ )
+
+ local_tool = ToolInfo(
+ name=local_tool_name,
+ tool_original_name=tool.tool_original_name,
+ description=tool.description,
+ service_name=local_service_name,
+ service_original_name=local_service_name,
+ service_global_name=service_global_name,
+ client_id=tool.client_id,
+ inputSchema=tool.inputSchema
+ )
+ result.append(local_tool)
+
+ return result
+
+ @staticmethod
+ def check_tool_availability(
+ service_global_name: str,
+ tool_name: str,
+ service_status: Optional[Dict[str, Any]],
+ tool_original_name_override: Optional[str] = None,
+ service_original_name: Optional[str] = None,
+ ) -> bool:
+ """
+ 检查单个工具的可用性
+
+ 纯同步计算,无 IO。
+ 遇到错误必须抛出,不做静默处理。
+
+ Args:
+ service_global_name: 服务全局名称
+ tool_name: 工具名称
+ service_status: 服务状态数据(从 pykv 状态层读取)
+
+ Returns:
+ True 如果工具可用,否则 False
+
+ Raises:
+ RuntimeError: 如果服务状态不存在或工具状态不存在
+ """
+ if service_status is None:
+ raise RuntimeError(
+ f"Service state does not exist, cannot check tool availability: "
+ f"service_global_name={service_global_name}, tool={tool_name}"
+ )
+
+ # 提取工具原始名称
+ original_tool_name = (
+ tool_original_name_override
+ or ToolLogicCore.extract_original_tool_name(
+ tool_name,
+ service_global_name,
+ service_original_name
+ )
+ )
+
+ # 查找工具状态
+ tools = service_status.get("tools", [])
+
+ tool_status = None
+ for ts in tools:
+ if ts.get("tool_original_name") == original_tool_name:
+ tool_status = ts
+ break
+
+ if tool_status is None:
+ raise RuntimeError(
+ f"Tool does not exist in service state: "
+ f"service_global_name={service_global_name}, "
+ f"tool={tool_name}, original_name={original_tool_name}"
+ )
+
+ return tool_status.get("status") == "available"
diff --git a/src/mcpstore/core/models/__init__.py b/src/mcpstore/core/models/__init__.py
new file mode 100644
index 00000000..59ccde4a
--- /dev/null
+++ b/src/mcpstore/core/models/__init__.py
@@ -0,0 +1,137 @@
+"""
+MCPStore Data Models Unified Import Module
+
+Provides unified import interface for all data models, avoiding duplicate definitions and import confusion.
+"""
+
+# Client-related models
+from .client import (
+ ClientRegistrationRequest
+)
+# Common response models (兼容性保留)
+from .common import (
+ ListResponse,
+ DataResponse,
+ RegistrationResponse,
+ ExecutionResponse,
+ ConfigResponse,
+ HealthResponse
+)
+# 错误码枚举
+from .error_codes import ErrorCode
+# ==================== 核心响应架构 ====================
+# 响应模型
+from .response import (
+ APIResponse,
+ ErrorDetail,
+ ResponseMeta,
+ Pagination
+)
+# 响应构造器
+from .response_builder import (
+ ResponseBuilder,
+ TimedResponseBuilder
+)
+# 响应装饰器
+from .response_decorators import (
+ timed_response,
+ paginated,
+ handle_errors,
+ api_endpoint
+)
+# Service-related models
+from .service import (
+ ServiceInfo,
+ ServiceInfoResponse,
+ ServicesResponse,
+ RegisterRequestUnion,
+ JsonUpdateRequest,
+ ServiceConfig,
+ URLServiceConfig,
+ CommandServiceConfig,
+ MCPServerConfig,
+ ServiceConfigUnion,
+ AddServiceRequest,
+ TransportType,
+ ServiceConnectionState,
+ ServiceStateMetadata
+)
+# Tool-related models
+from .tool import (
+ ToolInfo,
+ ToolsResponse,
+ ToolExecutionRequest
+)
+# Tool result helpers
+from .tool_result import CallToolFailureResult
+# Tool set management models
+from .tool_set import (
+ ToolSetState
+)
+
+# Configuration management related
+try:
+ from ..configuration.unified_config import UnifiedConfigManager, ConfigType, ConfigInfo
+except ImportError:
+ # Avoid circular import issues
+ pass
+
+# Export all models for convenient external import
+__all__ = [
+ # ==================== Response Architecture ====================
+ # Response models
+ 'APIResponse',
+ 'ErrorDetail',
+ 'ResponseMeta',
+ 'Pagination',
+
+ # Response builders
+ 'ResponseBuilder',
+ 'TimedResponseBuilder',
+
+ # Error codes
+ 'ErrorCode',
+
+ # Response decorators
+ 'timed_response',
+ 'paginated',
+ 'handle_errors',
+ 'api_endpoint',
+
+ # ==================== Domain Models ====================
+ # Service models
+ 'ServiceInfo',
+ 'ServiceInfoResponse',
+ 'ServicesResponse',
+ 'RegisterRequestUnion',
+ 'JsonUpdateRequest',
+ 'ServiceConfig',
+ 'URLServiceConfig',
+ 'CommandServiceConfig',
+ 'MCPServerConfig',
+ 'ServiceConfigUnion',
+ 'AddServiceRequest',
+ 'TransportType',
+ 'ServiceConnectionState',
+ 'ServiceStateMetadata',
+
+ # Tool models
+ 'ToolInfo',
+ 'ToolsResponse',
+ 'ToolExecutionRequest',
+ 'CallToolFailureResult',
+
+ # Tool set management models
+ 'ToolSetState',
+
+ # Client models
+ 'ClientRegistrationRequest',
+
+ # Common response models (兼容性保留)
+ 'ListResponse',
+ 'DataResponse',
+ 'RegistrationResponse',
+ 'ExecutionResponse',
+ 'ConfigResponse',
+ 'HealthResponse'
+]
diff --git a/src/mcpstore/core/models/agent.py b/src/mcpstore/core/models/agent.py
new file mode 100644
index 00000000..0a1b6f44
--- /dev/null
+++ b/src/mcpstore/core/models/agent.py
@@ -0,0 +1,65 @@
+"""
+Agent-related data models
+"""
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict, List, Optional, Any
+
+from .service import ServiceConnectionState, ServiceStateMetadata
+
+
+@dataclass
+class AgentInfo:
+ """Agent information"""
+ agent_id: str
+ name: Optional[str] = None
+ description: Optional[str] = None
+ created_at: Optional[datetime] = None
+ last_active: Optional[datetime] = None
+ metadata: Optional[Dict[str, Any]] = None
+
+@dataclass
+class AgentServiceSummary:
+ """Agent service summary"""
+ service_name: str
+ service_type: str # "local" | "remote" | "sse" | "stdio"
+ status: ServiceConnectionState # Use new 7-state enumeration
+ tool_count: int
+ last_used: Optional[datetime] = None
+ client_id: Optional[str] = None
+ # New lifecycle-related fields
+ response_time: Optional[float] = None
+ health_details: Optional[ServiceStateMetadata] = None
+
+@dataclass
+class AgentStatistics:
+ """Agent statistics information"""
+ agent_id: str
+ service_count: int
+ tool_count: int
+ healthy_services: int
+ unhealthy_services: int
+ total_tool_executions: int
+ is_active: bool = False # [REFACTOR] 添加缺失的is_active字段
+ last_activity: Optional[datetime] = None
+ services: List[AgentServiceSummary] = None
+
+ def __post_init__(self):
+ if self.services is None:
+ self.services = []
+
+@dataclass
+class AgentsSummary:
+ """所有Agent的汇总信息"""
+ total_agents: int
+ active_agents: int # 有服务的Agent数量
+ total_services: int
+ total_tools: int
+ store_services: int # Store级别的服务数量
+ store_tools: int # Store级别的工具数量
+ agents: List[AgentStatistics] = None
+
+ def __post_init__(self):
+ if self.agents is None:
+ self.agents = []
diff --git a/src/mcpstore/core/models/client.py b/src/mcpstore/core/models/client.py
index 5566b1ca..0b7cb2c0 100644
--- a/src/mcpstore/core/models/client.py
+++ b/src/mcpstore/core/models/client.py
@@ -1,11 +1,10 @@
-from pydantic import BaseModel
-from typing import Optional, List, Dict, Any
+from typing import Optional, List
+
+from pydantic import BaseModel, Field
+
class ClientRegistrationRequest(BaseModel):
- client_id: Optional[str] = None
- service_names: Optional[List[str]] = None
+ client_id: Optional[str] = Field(None, description="Client ID")
+ service_names: Optional[List[str]] = Field(None, description="Service name list")
-class ClientRegistrationResponse(BaseModel):
- client_id: str
- service_names: List[str]
- config: Dict[str, Any]
+# ClientRegistrationResponse has been moved to common.py, please import directly from common.py
diff --git a/src/mcpstore/core/models/common.py b/src/mcpstore/core/models/common.py
new file mode 100644
index 00000000..90364301
--- /dev/null
+++ b/src/mcpstore/core/models/common.py
@@ -0,0 +1,58 @@
+"""
+MCPStore Common Response Models
+
+Unified response model import center.
+"""
+
+# ==================== Core Response Models ====================
+
+# Response builders
+
+# Response decorators
+
+# Error code enumeration
+
+# ==================== Compatibility exports (some legacy models) ====================
+from typing import Optional, Any, List, Dict, Generic, TypeVar
+
+from pydantic import BaseModel, Field
+
+T = TypeVar('T')
+
+class ListResponse(BaseModel, Generic[T]):
+ """List response model"""
+ success: bool = Field(..., description="Whether operation was successful")
+ message: Optional[str] = Field(None, description="Response message")
+ items: List[T] = Field(..., description="Data item list")
+ total: int = Field(..., description="Total count")
+
+class DataResponse(BaseModel, Generic[T]):
+ """Data response model"""
+ success: bool = Field(..., description="Whether operation was successful")
+ message: Optional[str] = Field(None, description="Response message")
+ data: T = Field(..., description="Response data")
+
+class RegistrationResponse(BaseModel):
+ """Service registration response"""
+ success: bool = Field(..., description="Whether operation was successful")
+ message: str = Field(..., description="Response message")
+ service_name: Optional[str] = Field(None, description="Registered service name")
+
+class ExecutionResponse(BaseModel):
+ """Tool execution response"""
+ success: bool = Field(..., description="Whether operation was successful")
+ message: Optional[str] = Field(None, description="Response message")
+ result: Optional[Any] = Field(None, description="Execution result")
+ error: Optional[str] = Field(None, description="Error message")
+
+class ConfigResponse(BaseModel):
+ """Configuration operation response"""
+ success: bool = Field(..., description="Whether operation was successful")
+ message: str = Field(..., description="Response message")
+ config: Optional[Dict[str, Any]] = Field(None, description="Configuration data")
+
+class HealthResponse(BaseModel):
+ """Health check response"""
+ success: bool = Field(..., description="Whether operation was successful")
+ status: str = Field(..., description="Health status")
+ services: Optional[Dict[str, str]] = Field(None, description="Service status mapping")
diff --git a/src/mcpstore/core/models/error_codes.py b/src/mcpstore/core/models/error_codes.py
new file mode 100644
index 00000000..d02c9102
--- /dev/null
+++ b/src/mcpstore/core/models/error_codes.py
@@ -0,0 +1,315 @@
+"""
+标准错误码定义(增强版)
+
+特性:
+- 使用Enum提供类型安全
+- 支持HTTP状态码映射
+- 支持错误描述(国际化准备)
+- 分类管理
+
+创建日期: 2025-10-01
+"""
+
+from enum import Enum
+from typing import Dict
+
+
+class ErrorCode(str, Enum):
+ """标准错误码枚举(增强版)
+
+ 分类:
+ - 1xxx: 通用错误
+ - 2xxx: 服务相关
+ - 3xxx: 工具相关
+ - 4xxx: Agent相关
+ - 5xxx: 配置相关
+ - 6xxx: 认证相关
+
+ 使用示例:
+ from mcpstore.core.models.error_codes import ErrorCode
+
+ # 使用错误码
+ code = ErrorCode.SERVICE_NOT_FOUND
+
+ # 获取HTTP状态码
+ status = code.to_http_status() # 404
+
+ # 获取错误描述
+ desc = code.get_description() # "The requested service does not exist"
+ """
+
+ # ==================== 通用错误 (1xxx) ====================
+
+ INTERNAL_ERROR = "INTERNAL_ERROR"
+ """服务器内部错误。意外的异常或系统故障"""
+
+ INVALID_PARAMETER = "INVALID_PARAMETER"
+ """参数无效。参数格式错误、类型错误或不符合要求"""
+
+ MISSING_PARAMETER = "MISSING_PARAMETER"
+ """缺少必需参数。必填字段未提供"""
+
+ INVALID_REQUEST = "INVALID_REQUEST"
+ """请求无效。请求格式错误或不符合API规范"""
+
+ OPERATION_TIMEOUT = "OPERATION_TIMEOUT"
+ """操作超时。操作执行时间超过限制"""
+
+ RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
+ """速率限制超出。请求频率超过限制"""
+
+ # ==================== 服务相关 (2xxx) ====================
+
+ SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
+ """服务未找到。指定的服务名称不存在"""
+
+ SERVICE_ALREADY_EXISTS = "SERVICE_ALREADY_EXISTS"
+ """服务已存在。尝试添加重复的服务"""
+
+ SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
+ """服务不可用。服务处于不可用状态(disconnected/unreachable)"""
+
+ SERVICE_TIMEOUT = "SERVICE_TIMEOUT"
+ """服务超时。连接或操作服务时超时"""
+
+ SERVICE_CONNECTION_FAILED = "SERVICE_CONNECTION_FAILED"
+ """服务连接失败。无法建立与服务的连接"""
+
+ SERVICE_INITIALIZATION_FAILED = "SERVICE_INITIALIZATION_FAILED"
+ """服务初始化失败。服务启动或初始化过程出错"""
+
+ SERVICE_CONFIGURATION_INVALID = "SERVICE_CONFIGURATION_INVALID"
+ """服务配置无效。配置参数不正确或缺失"""
+
+ SERVICE_OPERATION_FAILED = "SERVICE_OPERATION_FAILED"
+ """服务操作失败。通用服务操作执行失败"""
+
+ # ==================== 工具相关 (3xxx) ====================
+
+ TOOL_NOT_FOUND = "TOOL_NOT_FOUND"
+ """工具未找到。指定的工具名称不存在"""
+
+ TOOL_EXECUTION_FAILED = "TOOL_EXECUTION_FAILED"
+ """工具执行失败。工具运行时发生错误"""
+
+ TOOL_PARAMETER_INVALID = "TOOL_PARAMETER_INVALID"
+ """工具参数无效。提供的参数不符合工具要求"""
+
+ TOOL_TIMEOUT = "TOOL_TIMEOUT"
+ """工具执行超时。工具执行时间超过限制"""
+
+ TOOL_UNAVAILABLE = "TOOL_UNAVAILABLE"
+ """工具不可用。工具所属服务不可用或工具被禁用"""
+
+ # ==================== Agent相关 (4xxx) ====================
+
+ AGENT_NOT_FOUND = "AGENT_NOT_FOUND"
+ """Agent未找到。指定的Agent ID不存在"""
+
+ AGENT_ALREADY_EXISTS = "AGENT_ALREADY_EXISTS"
+ """Agent已存在。尝试创建重复的Agent"""
+
+ AGENT_OPERATION_FAILED = "AGENT_OPERATION_FAILED"
+ """Agent操作失败。Agent级别操作执行失败"""
+
+ # ==================== 配置相关 (5xxx) ====================
+
+ CONFIG_NOT_FOUND = "CONFIG_NOT_FOUND"
+ """配置未找到。指定的配置项不存在"""
+
+ CONFIG_INVALID = "CONFIG_INVALID"
+ """配置无效。配置格式或内容不正确"""
+
+ CONFIG_UPDATE_FAILED = "CONFIG_UPDATE_FAILED"
+ """配置更新失败。更新配置时发生错误"""
+
+ # ==================== 认证相关 (6xxx) ====================
+
+ AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED"
+ """需要认证。访问受保护资源但未提供认证信息"""
+
+ AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
+ """认证失败。提供的认证信息无效"""
+
+ AUTHORIZATION_FAILED = "AUTHORIZATION_FAILED"
+ """授权失败。认证成功但无权限执行操作"""
+
+ TOKEN_EXPIRED = "TOKEN_EXPIRED"
+ """令牌过期。认证令牌已过期需要刷新"""
+
+ TOKEN_INVALID = "TOKEN_INVALID"
+ """令牌无效。提供的令牌格式错误或被篡改"""
+
+ # ==================== 增强方法 ====================
+
+ def to_http_status(self) -> int:
+ """映射到HTTP状态码
+
+ Returns:
+ int: HTTP状态码(如404, 500等)
+
+ Example:
+ >>> ErrorCode.SERVICE_NOT_FOUND.to_http_status()
+ 404
+ """
+ return _ERROR_CODE_TO_HTTP_STATUS.get(self, 500)
+
+ def get_description(self) -> str:
+ """获取错误描述(英文)
+
+ Returns:
+ str: 错误的详细描述
+
+ Example:
+ >>> ErrorCode.SERVICE_NOT_FOUND.get_description()
+ 'The requested service does not exist'
+ """
+ return _ERROR_CODE_DESCRIPTIONS.get(self, "An error occurred")
+
+ def get_category(self) -> str:
+ """获取错误分类
+
+ Returns:
+ str: 错误分类名称
+
+ Example:
+ >>> ErrorCode.SERVICE_NOT_FOUND.get_category()
+ 'Service'
+ """
+ return _ERROR_CODE_CATEGORIES.get(self, "Unknown")
+
+
+# ==================== 映射表 ====================
+
+_ERROR_CODE_TO_HTTP_STATUS: Dict[ErrorCode, int] = {
+ # 通用错误
+ ErrorCode.INTERNAL_ERROR: 500,
+ ErrorCode.INVALID_PARAMETER: 400,
+ ErrorCode.MISSING_PARAMETER: 400,
+ ErrorCode.INVALID_REQUEST: 400,
+ ErrorCode.OPERATION_TIMEOUT: 408,
+ ErrorCode.RATE_LIMIT_EXCEEDED: 429,
+
+ # 服务相关
+ ErrorCode.SERVICE_NOT_FOUND: 404,
+ ErrorCode.SERVICE_ALREADY_EXISTS: 409,
+ ErrorCode.SERVICE_UNAVAILABLE: 503,
+ ErrorCode.SERVICE_TIMEOUT: 408,
+ ErrorCode.SERVICE_CONNECTION_FAILED: 503,
+ ErrorCode.SERVICE_INITIALIZATION_FAILED: 500,
+ ErrorCode.SERVICE_CONFIGURATION_INVALID: 400,
+ ErrorCode.SERVICE_OPERATION_FAILED: 500,
+
+ # 工具相关
+ ErrorCode.TOOL_NOT_FOUND: 404,
+ ErrorCode.TOOL_EXECUTION_FAILED: 500,
+ ErrorCode.TOOL_PARAMETER_INVALID: 400,
+ ErrorCode.TOOL_TIMEOUT: 408,
+ ErrorCode.TOOL_UNAVAILABLE: 503,
+
+ # Agent相关
+ ErrorCode.AGENT_NOT_FOUND: 404,
+ ErrorCode.AGENT_ALREADY_EXISTS: 409,
+ ErrorCode.AGENT_OPERATION_FAILED: 500,
+
+ # 配置相关
+ ErrorCode.CONFIG_NOT_FOUND: 404,
+ ErrorCode.CONFIG_INVALID: 400,
+ ErrorCode.CONFIG_UPDATE_FAILED: 500,
+
+ # 认证相关
+ ErrorCode.AUTHENTICATION_REQUIRED: 401,
+ ErrorCode.AUTHENTICATION_FAILED: 401,
+ ErrorCode.AUTHORIZATION_FAILED: 403,
+ ErrorCode.TOKEN_EXPIRED: 401,
+ ErrorCode.TOKEN_INVALID: 401,
+}
+
+_ERROR_CODE_DESCRIPTIONS: Dict[ErrorCode, str] = {
+ # 通用错误
+ ErrorCode.INTERNAL_ERROR: "An unexpected internal server error occurred",
+ ErrorCode.INVALID_PARAMETER: "One or more parameters are invalid",
+ ErrorCode.MISSING_PARAMETER: "A required parameter is missing",
+ ErrorCode.INVALID_REQUEST: "The request format is invalid",
+ ErrorCode.OPERATION_TIMEOUT: "The operation timed out",
+ ErrorCode.RATE_LIMIT_EXCEEDED: "Rate limit exceeded, please try again later",
+
+ # 服务相关
+ ErrorCode.SERVICE_NOT_FOUND: "The requested service does not exist",
+ ErrorCode.SERVICE_ALREADY_EXISTS: "A service with this name already exists",
+ ErrorCode.SERVICE_UNAVAILABLE: "The service is currently unavailable",
+ ErrorCode.SERVICE_TIMEOUT: "Service connection or operation timed out",
+ ErrorCode.SERVICE_CONNECTION_FAILED: "Failed to connect to the service",
+ ErrorCode.SERVICE_INITIALIZATION_FAILED: "Service initialization failed",
+ ErrorCode.SERVICE_CONFIGURATION_INVALID: "Service configuration is invalid",
+ ErrorCode.SERVICE_OPERATION_FAILED: "Service operation failed",
+
+ # 工具相关
+ ErrorCode.TOOL_NOT_FOUND: "The requested tool does not exist",
+ ErrorCode.TOOL_EXECUTION_FAILED: "Tool execution failed",
+ ErrorCode.TOOL_PARAMETER_INVALID: "Tool parameters are invalid",
+ ErrorCode.TOOL_TIMEOUT: "Tool execution timed out",
+ ErrorCode.TOOL_UNAVAILABLE: "The tool is currently unavailable",
+
+ # Agent相关
+ ErrorCode.AGENT_NOT_FOUND: "The requested agent does not exist",
+ ErrorCode.AGENT_ALREADY_EXISTS: "An agent with this ID already exists",
+ ErrorCode.AGENT_OPERATION_FAILED: "Agent operation failed",
+
+ # 配置相关
+ ErrorCode.CONFIG_NOT_FOUND: "The requested configuration does not exist",
+ ErrorCode.CONFIG_INVALID: "The configuration is invalid",
+ ErrorCode.CONFIG_UPDATE_FAILED: "Failed to update configuration",
+
+ # 认证相关
+ ErrorCode.AUTHENTICATION_REQUIRED: "Authentication is required",
+ ErrorCode.AUTHENTICATION_FAILED: "Authentication failed",
+ ErrorCode.AUTHORIZATION_FAILED: "You do not have permission to perform this operation",
+ ErrorCode.TOKEN_EXPIRED: "Your authentication token has expired",
+ ErrorCode.TOKEN_INVALID: "The authentication token is invalid",
+}
+
+_ERROR_CODE_CATEGORIES: Dict[ErrorCode, str] = {
+ # 通用错误
+ ErrorCode.INTERNAL_ERROR: "General",
+ ErrorCode.INVALID_PARAMETER: "General",
+ ErrorCode.MISSING_PARAMETER: "General",
+ ErrorCode.INVALID_REQUEST: "General",
+ ErrorCode.OPERATION_TIMEOUT: "General",
+ ErrorCode.RATE_LIMIT_EXCEEDED: "General",
+
+ # 服务相关
+ ErrorCode.SERVICE_NOT_FOUND: "Service",
+ ErrorCode.SERVICE_ALREADY_EXISTS: "Service",
+ ErrorCode.SERVICE_UNAVAILABLE: "Service",
+ ErrorCode.SERVICE_TIMEOUT: "Service",
+ ErrorCode.SERVICE_CONNECTION_FAILED: "Service",
+ ErrorCode.SERVICE_INITIALIZATION_FAILED: "Service",
+ ErrorCode.SERVICE_CONFIGURATION_INVALID: "Service",
+ ErrorCode.SERVICE_OPERATION_FAILED: "Service",
+
+ # 工具相关
+ ErrorCode.TOOL_NOT_FOUND: "Tool",
+ ErrorCode.TOOL_EXECUTION_FAILED: "Tool",
+ ErrorCode.TOOL_PARAMETER_INVALID: "Tool",
+ ErrorCode.TOOL_TIMEOUT: "Tool",
+ ErrorCode.TOOL_UNAVAILABLE: "Tool",
+
+ # Agent相关
+ ErrorCode.AGENT_NOT_FOUND: "Agent",
+ ErrorCode.AGENT_ALREADY_EXISTS: "Agent",
+ ErrorCode.AGENT_OPERATION_FAILED: "Agent",
+
+ # 配置相关
+ ErrorCode.CONFIG_NOT_FOUND: "Configuration",
+ ErrorCode.CONFIG_INVALID: "Configuration",
+ ErrorCode.CONFIG_UPDATE_FAILED: "Configuration",
+
+ # 认证相关
+ ErrorCode.AUTHENTICATION_REQUIRED: "Authentication",
+ ErrorCode.AUTHENTICATION_FAILED: "Authentication",
+ ErrorCode.AUTHORIZATION_FAILED: "Authentication",
+ ErrorCode.TOKEN_EXPIRED: "Authentication",
+ ErrorCode.TOKEN_INVALID: "Authentication",
+}
+
diff --git a/src/mcpstore/core/models/response.py b/src/mcpstore/core/models/response.py
new file mode 100644
index 00000000..b67189dc
--- /dev/null
+++ b/src/mcpstore/core/models/response.py
@@ -0,0 +1,302 @@
+"""
+MCPStore API 响应模型
+
+统一的API响应架构,提供:
+- 统一的响应结构
+- 标准化的错误处理
+- 完整的追踪信息
+- 类型安全的数据模型
+
+创建日期: 2025-10-01
+"""
+
+from typing import Optional, Any, List, Dict, Union
+
+from pydantic import BaseModel, Field, ConfigDict
+
+
+class ErrorDetail(BaseModel):
+ """错误详情模型
+
+ 用于描述单个错误的详细信息。支持:
+ - 标准错误码(用于程序判断)
+ - 人类可读消息(用于显示)
+ - 相关字段(用于表单验证)
+ - 额外详情(用于调试)
+
+ 示例:
+ # 通用错误
+ ErrorDetail(
+ code="SERVICE_NOT_FOUND",
+ message="Service 'weather' does not exist",
+ details={"service_name": "weather"}
+ )
+
+ # 验证错误
+ ErrorDetail(
+ code="INVALID_PARAMETER",
+ message="Field 'url' is required",
+ field="url",
+ details={"provided": None, "expected": "string"}
+ )
+ """
+
+ code: str = Field(
+ ...,
+ description="标准错误码。大写下划线格式,用于程序判断错误类型",
+ json_schema_extra={"example": "SERVICE_NOT_FOUND"},
+ pattern="^[A-Z_]+$"
+ )
+
+ message: str = Field(
+ ...,
+ description="人类可读的错误消息。可用于直接显示给用户",
+ json_schema_extra={"example": "The requested service does not exist"}
+ )
+
+ field: Optional[str] = Field(
+ None,
+ description="相关字段名。用于表单验证错误,指明哪个字段出错",
+ json_schema_extra={"example": "service_name"}
+ )
+
+ details: Optional[Dict[str, Any]] = Field(
+ None,
+ description="错误的额外详情信息。包含有助于调试的上下文",
+ json_schema_extra={"example": {"service_name": "weather", "attempted_operation": "get_status"}}
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "code": "INVALID_PARAMETER",
+ "message": "The 'url' parameter is required but was not provided",
+ "field": "url",
+ "details": {
+ "provided_value": None,
+ "expected_type": "string",
+ "parameter_name": "url"
+ }
+ }
+ }
+ )
+
+
+class ResponseMeta(BaseModel):
+ """响应元数据模型
+
+ 包含所有追踪、性能、版本信息。用于:
+ - 请求追踪(request_id)
+ - 性能监控(execution_time_ms)
+ - 时间记录(timestamp)
+ - 版本管理(api_version)
+
+ 所有字段都是必需的,确保元数据完整性。
+ """
+
+ timestamp: str = Field(
+ ...,
+ description="响应生成的ISO 8601时间戳(UTC)",
+ json_schema_extra={"example": "2025-10-01T12:00:00.000Z"}
+ )
+
+ request_id: str = Field(
+ ...,
+ description="唯一请求标识符。用于追踪和日志关联。格式:req_[16位随机字符]",
+ json_schema_extra={"example": "req_a1b2c3d4e5f6g7h8"},
+ min_length=20,
+ max_length=20
+ )
+
+ execution_time_ms: int = Field(
+ ...,
+ description="服务端执行时间(毫秒)。从接收请求到生成响应的耗时",
+ json_schema_extra={"example": 150},
+ ge=0
+ )
+
+ api_version: str = Field(
+ default="1.0.0",
+ description="API版本号。遵循语义化版本规范",
+ json_schema_extra={"example": "1.0.0"}
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "timestamp": "2025-10-01T12:00:00.000Z",
+ "request_id": "req_a1b2c3d4e5f6g7h8",
+ "execution_time_ms": 150,
+ "api_version": "2.0.0"
+ }
+ }
+ )
+
+
+class Pagination(BaseModel):
+ """分页信息模型
+
+ 仅在返回列表数据且支持分页时使用。
+ 提供完整的分页导航信息。
+
+ 计算规则:
+ - total_pages = ceil(total / page_size)
+ - has_next = page < total_pages
+ - has_prev = page > 1
+ """
+
+ page: int = Field(
+ ...,
+ description="当前页码(从1开始)",
+ json_schema_extra={"example": 1},
+ ge=1
+ )
+
+ page_size: int = Field(
+ ...,
+ description="每页记录数",
+ json_schema_extra={"example": 20},
+ ge=1,
+ le=100
+ )
+
+ total: int = Field(
+ ...,
+ description="总记录数",
+ json_schema_extra={"example": 100},
+ ge=0
+ )
+
+ total_pages: int = Field(
+ ...,
+ description="总页数",
+ json_schema_extra={"example": 5},
+ ge=0
+ )
+
+ has_next: bool = Field(
+ ...,
+ description="是否有下一页",
+ json_schema_extra={"example": True}
+ )
+
+ has_prev: bool = Field(
+ ...,
+ description="是否有上一页",
+ json_schema_extra={"example": False}
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "page": 1,
+ "page_size": 20,
+ "total": 100,
+ "total_pages": 5,
+ "has_next": True,
+ "has_prev": False
+ }
+ }
+ )
+
+
+class APIResponse(BaseModel):
+ """统一API响应模型
+
+ 设计原则:
+ - 所有API接口统一使用此模型
+ - 成功时返回data,失败时返回errors
+ - meta包含追踪和性能信息
+ - pagination仅在data为列表时使用
+
+ 示例:
+ # 成功响应
+ APIResponse(
+ success=True,
+ message="Service retrieved successfully",
+ data={"name": "weather", "status": "healthy"},
+ meta=ResponseMeta(...)
+ )
+
+ # 失败响应
+ APIResponse(
+ success=False,
+ message="Service not found",
+ data=None,
+ errors=[ErrorDetail(code="SERVICE_NOT_FOUND", ...)]
+ )
+ """
+
+ # 核心字段(必需)
+ success: bool = Field(
+ ...,
+ description="操作是否成功。true=成功, false=失败"
+ )
+
+ message: str = Field(
+ ...,
+ description="人类可读的响应消息。成功时描述操作结果,失败时描述错误原因",
+ json_schema_extra={"example": "Service retrieved successfully"}
+ )
+
+ # 数据字段(可选)
+ data: Optional[Union[Dict[str, Any], List[Any]]] = Field(
+ None,
+ description="响应数据。成功时包含实际数据,失败时为null。类型严格限制为Dict或List",
+ json_schema_extra={"example": {"name": "weather", "status": "healthy"}}
+ )
+
+ # 错误字段(可选,仅失败时)
+ errors: Optional[List[ErrorDetail]] = Field(
+ None,
+ description="错误详情列表。仅在success=false时存在。支持多个错误(如参数验证)",
+ json_schema_extra={"example": [{
+ "code": "SERVICE_NOT_FOUND",
+ "message": "The requested service does not exist",
+ "field": None,
+ "details": {"service_name": "weather"}
+ }]}
+ )
+
+ # 元数据字段(可选)
+ meta: Optional[ResponseMeta] = Field(
+ None,
+ description="响应元数据。包含追踪信息、性能指标、API版本等",
+ json_schema_extra={"example": {
+ "timestamp": "2025-10-01T12:00:00.000Z",
+ "request_id": "req_a1b2c3d4e5f6",
+ "execution_time_ms": 150,
+ "api_version": "2.0.0"
+ }}
+ )
+
+ # 分页字段(可选,仅列表时)
+ pagination: Optional[Pagination] = Field(
+ None,
+ description="分页信息。仅当data为列表且支持分页时存在",
+ json_schema_extra={"example": {
+ "page": 1,
+ "page_size": 20,
+ "total": 100,
+ "total_pages": 5,
+ "has_next": True,
+ "has_prev": False
+ }}
+ )
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "success": True,
+ "message": "Operation completed successfully",
+ "data": {"result": "ok"},
+ "meta": {
+ "timestamp": "2025-10-01T12:00:00.000Z",
+ "request_id": "req_abc123",
+ "execution_time_ms": 150,
+ "api_version": "2.0.0"
+ }
+ }
+ }
+ )
+
diff --git a/src/mcpstore/core/models/response_builder.py b/src/mcpstore/core/models/response_builder.py
new file mode 100644
index 00000000..d3238669
--- /dev/null
+++ b/src/mcpstore/core/models/response_builder.py
@@ -0,0 +1,288 @@
+"""
+响应构造器
+
+提供便捷的响应构造方法,确保:
+- 响应格式统一
+- 元数据自动生成
+- 类型安全
+
+创建日期: 2025-10-01
+"""
+
+import time
+import uuid
+from datetime import datetime
+from math import ceil
+from typing import Any, List, Dict, Optional, Union
+
+from .error_codes import ErrorCode
+from .response import APIResponse, ErrorDetail, ResponseMeta, Pagination
+
+
+class ResponseBuilder:
+ """响应构造器
+
+ 使用示例:
+ # 成功响应
+ response = ResponseBuilder.success(
+ message="Service retrieved",
+ data={"name": "weather"},
+ execution_time_ms=150
+ )
+
+ # 错误响应
+ response = ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message="Service not found",
+ details={"service_name": "weather"}
+ )
+ """
+
+ @staticmethod
+ def _generate_request_id() -> str:
+ """生成唯一请求ID"""
+ return f"req_{uuid.uuid4().hex[:16]}"
+
+ @staticmethod
+ def _get_timestamp() -> str:
+ """获取ISO 8601格式时间戳"""
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+
+ @staticmethod
+ def _create_meta(execution_time_ms: int, request_id: Optional[str] = None) -> ResponseMeta:
+ """创建元数据"""
+ return ResponseMeta(
+ timestamp=ResponseBuilder._get_timestamp(),
+ request_id=request_id or ResponseBuilder._generate_request_id(),
+ execution_time_ms=execution_time_ms,
+ api_version="1.0.0"
+ )
+
+ @staticmethod
+ def success(
+ message: str,
+ data: Optional[Union[Dict, List]] = None,
+ execution_time_ms: Optional[int] = None,
+ request_id: Optional[str] = None,
+ pagination: Optional[Dict] = None
+ ) -> APIResponse:
+ """构造成功响应
+
+ Args:
+ message: 响应消息
+ data: 响应数据(Dict或List)
+ execution_time_ms: 执行时间(毫秒)
+ request_id: 请求ID(自动生成)
+ pagination: 分页信息字典(仅data为List时)
+
+ Returns:
+ APIResponse对象
+ """
+ # 自动计算执行时间
+ if execution_time_ms is None:
+ execution_time_ms = 0
+
+ # 创建元数据
+ meta = ResponseBuilder._create_meta(execution_time_ms, request_id)
+
+ # 处理分页
+ pagination_obj = None
+ if pagination and isinstance(data, list):
+ pagination_obj = Pagination(**pagination)
+
+ return APIResponse(
+ success=True,
+ message=message,
+ data=data,
+ errors=None,
+ meta=meta,
+ pagination=pagination_obj
+ )
+
+ @staticmethod
+ def error(
+ code: Union[ErrorCode, str],
+ message: str,
+ field: Optional[str] = None,
+ details: Optional[Dict] = None,
+ execution_time_ms: Optional[int] = None,
+ request_id: Optional[str] = None
+ ) -> APIResponse:
+ """构造错误响应(单个错误)
+
+ Args:
+ code: 错误码(ErrorCode或字符串)
+ message: 错误消息
+ field: 相关字段(可选)
+ details: 详细信息(可选)
+ execution_time_ms: 执行时间(毫秒)
+ request_id: 请求ID(自动生成)
+
+ Returns:
+ APIResponse对象
+ """
+ if execution_time_ms is None:
+ execution_time_ms = 0
+
+ meta = ResponseBuilder._create_meta(execution_time_ms, request_id)
+
+ # 如果code是ErrorCode枚举,转换为字符串
+ code_str = code.value if isinstance(code, ErrorCode) else code
+
+ error = ErrorDetail(
+ code=code_str,
+ message=message,
+ field=field,
+ details=details
+ )
+
+ return APIResponse(
+ success=False,
+ message=message,
+ data=None,
+ errors=[error],
+ meta=meta,
+ pagination=None
+ )
+
+ @staticmethod
+ def errors(
+ message: str,
+ errors: List[Dict],
+ execution_time_ms: Optional[int] = None,
+ request_id: Optional[str] = None
+ ) -> APIResponse:
+ """构造错误响应(多个错误)
+
+ Args:
+ message: 总体错误消息
+ errors: 错误列表,每个元素包含code, message等
+ execution_time_ms: 执行时间(毫秒)
+ request_id: 请求ID(自动生成)
+
+ Returns:
+ APIResponse对象
+ """
+ if execution_time_ms is None:
+ execution_time_ms = 0
+
+ meta = ResponseBuilder._create_meta(execution_time_ms, request_id)
+
+ error_objects = [ErrorDetail(**e) for e in errors]
+
+ return APIResponse(
+ success=False,
+ message=message,
+ data=None,
+ errors=error_objects,
+ meta=meta,
+ pagination=None
+ )
+
+ @staticmethod
+ def paginated_list(
+ message: str,
+ items: List[Any],
+ page: int,
+ page_size: int,
+ total: int,
+ execution_time_ms: Optional[int] = None,
+ request_id: Optional[str] = None
+ ) -> APIResponse:
+ """构造分页列表响应
+
+ Args:
+ message: 响应消息
+ items: 当前页的数据列表
+ page: 当前页码
+ page_size: 每页大小
+ total: 总记录数
+ execution_time_ms: 执行时间(毫秒)
+ request_id: 请求ID(自动生成)
+
+ Returns:
+ APIResponse对象
+ """
+ total_pages = ceil(total / page_size) if page_size > 0 else 0
+
+ pagination = Pagination(
+ page=page,
+ page_size=page_size,
+ total=total,
+ total_pages=total_pages,
+ has_next=page < total_pages,
+ has_prev=page > 1
+ )
+
+ return ResponseBuilder.success(
+ message=message,
+ data=items,
+ execution_time_ms=execution_time_ms,
+ request_id=request_id,
+ pagination=pagination.dict()
+ )
+
+
+class TimedResponseBuilder:
+ """带计时的响应构造器
+
+ 使用with语句自动计算执行时间:
+ with TimedResponseBuilder() as builder:
+ # ... 执行操作 ...
+ result = some_operation()
+
+ return builder.success(
+ message="Operation completed",
+ data=result
+ )
+ """
+
+ def __init__(self):
+ self.start_time = None
+ self.request_id = ResponseBuilder._generate_request_id()
+
+ def __enter__(self):
+ self.start_time = time.time()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ pass
+
+ def _get_execution_time(self) -> int:
+ """获取执行时间(毫秒)"""
+ if self.start_time is None:
+ return 0
+ return int((time.time() - self.start_time) * 1000)
+
+ def success(self, message: str, data: Optional[Union[Dict, List]] = None, **kwargs) -> APIResponse:
+ """构造成功响应(自动计时)"""
+ return ResponseBuilder.success(
+ message=message,
+ data=data,
+ execution_time_ms=self._get_execution_time(),
+ request_id=self.request_id,
+ **kwargs
+ )
+
+ def error(self, code: Union[ErrorCode, str], message: str, **kwargs) -> APIResponse:
+ """构造错误响应(自动计时)"""
+ return ResponseBuilder.error(
+ code=code,
+ message=message,
+ execution_time_ms=self._get_execution_time(),
+ request_id=self.request_id,
+ **kwargs
+ )
+
+ def paginated_list(self, message: str, items: List, page: int, page_size: int, total: int) -> APIResponse:
+ """构造分页响应(自动计时)"""
+ return ResponseBuilder.paginated_list(
+ message=message,
+ items=items,
+ page=page,
+ page_size=page_size,
+ total=total,
+ execution_time_ms=self._get_execution_time(),
+ request_id=self.request_id
+ )
+
diff --git a/src/mcpstore/core/models/response_decorators.py b/src/mcpstore/core/models/response_decorators.py
new file mode 100644
index 00000000..02c12b6b
--- /dev/null
+++ b/src/mcpstore/core/models/response_decorators.py
@@ -0,0 +1,435 @@
+"""
+Response decorators (implementation of improvements #1-3)
+
+Provides three core decorators:
+1. @timed_response - Automatic timing and response wrapping
+2. @paginated - Automatic pagination handling
+3. @handle_errors - Unified error handling
+
+Created: 2025-10-01
+"""
+
+import logging
+import time
+from functools import wraps
+from math import ceil
+from typing import Callable, Optional
+
+from .error_codes import ErrorCode
+from .response import APIResponse
+from .response_builder import ResponseBuilder
+
+logger = logging.getLogger(__name__)
+
+
+def timed_response(func: Callable) -> Callable:
+ """Automatic timing response decorator (improvement #1)
+
+ Features:
+ - Automatic execution time calculation
+ - Automatic request_id generation
+ - Automatic meta information injection
+ - Support for both sync and async functions
+
+ Usage example:
+ @timed_response
+ async def my_api():
+ result = do_work()
+ # Return data directly, decorator auto-wraps
+ return {"result": result}
+
+ # 或返回完整响应
+ @timed_response
+ async def my_api2():
+ return ResponseBuilder.success(data={"result": "ok"})
+
+ 优点:
+ - 无需手动使用 with TimedResponseBuilder()
+ - 代码更简洁
+ - 自动处理异常
+ """
+
+ @wraps(func)
+ async def async_wrapper(*args, **kwargs):
+ start_time = time.time()
+ request_id = ResponseBuilder._generate_request_id()
+
+ try:
+ result = await func(*args, **kwargs)
+ execution_time_ms = int((time.time() - start_time) * 1000)
+
+ # 如果返回的已经是APIResponse,注入meta
+ if isinstance(result, APIResponse):
+ if result.meta is None:
+ result.meta = ResponseBuilder._create_meta(
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+ return result
+
+ # 如果返回的是dict或list,自动包装为成功响应
+ if isinstance(result, (dict, list)):
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data=result,
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ # 其他类型,转换为data
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data={"result": result},
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ except Exception as e:
+ execution_time_ms = int((time.time() - start_time) * 1000)
+ logger.exception(f"Error in {func.__name__}: {e}")
+
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"An error occurred: {str(e)}",
+ details={"function": func.__name__, "error_type": type(e).__name__},
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ @wraps(func)
+ def sync_wrapper(*args, **kwargs):
+ start_time = time.time()
+ request_id = ResponseBuilder._generate_request_id()
+
+ try:
+ result = func(*args, **kwargs)
+ execution_time_ms = int((time.time() - start_time) * 1000)
+
+ if isinstance(result, APIResponse):
+ if result.meta is None:
+ result.meta = ResponseBuilder._create_meta(
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+ return result
+
+ if isinstance(result, (dict, list)):
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data=result,
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data={"result": result},
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ except Exception as e:
+ execution_time_ms = int((time.time() - start_time) * 1000)
+ logger.exception(f"Error in {func.__name__}: {e}")
+
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"An error occurred: {str(e)}",
+ details={"function": func.__name__, "error_type": type(e).__name__},
+ execution_time_ms=execution_time_ms,
+ request_id=request_id
+ )
+
+ # 判断是异步还是同步函数
+ import asyncio
+ if asyncio.iscoroutinefunction(func):
+ return async_wrapper
+ else:
+ return sync_wrapper
+
+
+def paginated(
+ default_page_size: int = 20,
+ max_page_size: int = 100,
+ page_param: str = "page",
+ page_size_param: str = "page_size"
+) -> Callable:
+ """自动分页装饰器(改进建议 #3)
+
+ 功能:
+ - 自动提取分页参数
+ - 自动计算分页信息
+ - 自动包装分页响应
+
+ 使用示例:
+ @paginated(default_page_size=20)
+ async def list_services(page: int = 1, page_size: int = 20):
+ # 只需返回 items 和 total
+ items = get_services(offset=(page-1)*page_size, limit=page_size)
+ total = count_services()
+ return items, total # 自动转换为分页响应
+
+ 优点:
+ - 无需手动构造Pagination对象
+ - 自动验证分页参数
+ - 统一分页逻辑
+
+ Args:
+ default_page_size: 默认每页大小
+ max_page_size: 最大每页大小
+ page_param: 页码参数名
+ page_size_param: 每页大小参数名
+ """
+
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ async def async_wrapper(*args, **kwargs):
+ # 提取分页参数
+ page = kwargs.get(page_param, 1)
+ page_size = kwargs.get(page_size_param, default_page_size)
+
+ # 验证分页参数
+ page = max(1, int(page))
+ page_size = max(1, min(int(page_size), max_page_size))
+
+ # 更新参数
+ kwargs[page_param] = page
+ kwargs[page_size_param] = page_size
+
+ try:
+ result = await func(*args, **kwargs)
+
+ # 期望返回 (items, total) 元组
+ if isinstance(result, tuple) and len(result) == 2:
+ items, total = result
+
+ return ResponseBuilder.paginated_list(
+ message=f"Retrieved {len(items)} items (page {page}/{ceil(total/page_size) if page_size > 0 else 0})",
+ items=items,
+ page=page,
+ page_size=page_size,
+ total=total
+ )
+
+ # 如果返回的已经是APIResponse,直接返回
+ if isinstance(result, APIResponse):
+ return result
+
+ # 其他情况,当作列表处理
+ if isinstance(result, list):
+ return ResponseBuilder.paginated_list(
+ message=f"Retrieved {len(result)} items",
+ items=result,
+ page=page,
+ page_size=page_size,
+ total=len(result)
+ )
+
+ raise ValueError(f"Paginated function must return (items, total) tuple, got {type(result)}")
+
+ except Exception as e:
+ logger.exception(f"Error in paginated function {func.__name__}: {e}")
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"Failed to retrieve paginated data: {str(e)}",
+ details={"function": func.__name__}
+ )
+
+ @wraps(func)
+ def sync_wrapper(*args, **kwargs):
+ page = kwargs.get(page_param, 1)
+ page_size = kwargs.get(page_size_param, default_page_size)
+
+ page = max(1, int(page))
+ page_size = max(1, min(int(page_size), max_page_size))
+
+ kwargs[page_param] = page
+ kwargs[page_size_param] = page_size
+
+ try:
+ result = func(*args, **kwargs)
+
+ if isinstance(result, tuple) and len(result) == 2:
+ items, total = result
+
+ return ResponseBuilder.paginated_list(
+ message=f"Retrieved {len(items)} items (page {page}/{ceil(total/page_size) if page_size > 0 else 0})",
+ items=items,
+ page=page,
+ page_size=page_size,
+ total=total
+ )
+
+ if isinstance(result, APIResponse):
+ return result
+
+ if isinstance(result, list):
+ return ResponseBuilder.paginated_list(
+ message=f"Retrieved {len(result)} items",
+ items=result,
+ page=page,
+ page_size=page_size,
+ total=len(result)
+ )
+
+ raise ValueError(f"Paginated function must return (items, total) tuple, got {type(result)}")
+
+ except Exception as e:
+ logger.exception(f"Error in paginated function {func.__name__}: {e}")
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"Failed to retrieve paginated data: {str(e)}",
+ details={"function": func.__name__}
+ )
+
+ import asyncio
+ if asyncio.iscoroutinefunction(func):
+ return async_wrapper
+ else:
+ return sync_wrapper
+
+ return decorator
+
+
+def handle_errors(
+ error_code: ErrorCode = ErrorCode.INTERNAL_ERROR,
+ custom_message: Optional[str] = None
+) -> Callable:
+ """统一错误处理装饰器(改进建议 #2的辅助)
+
+ 功能:
+ - 捕获函数中的异常
+ - 自动转换为标准错误响应
+ - 支持自定义错误码和消息
+
+ 使用示例:
+ @handle_errors(error_code=ErrorCode.SERVICE_NOT_FOUND)
+ async def get_service(name: str):
+ service = find_service(name)
+ if not service:
+ raise ValueError(f"Service {name} not found")
+ return service
+
+ 优点:
+ - 统一错误处理逻辑
+ - 自动记录日志
+ - 减少重复代码
+
+ Args:
+ error_code: 默认错误码
+ custom_message: 自定义错误消息模板
+ """
+
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ async def async_wrapper(*args, **kwargs):
+ try:
+ result = await func(*args, **kwargs)
+
+ # 如果已经是APIResponse,直接返回
+ if isinstance(result, APIResponse):
+ return result
+
+ # 其他情况,包装为成功响应
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data=result if isinstance(result, (dict, list)) else {"result": result}
+ )
+
+ except Exception as e:
+ logger.exception(f"Error in {func.__name__}: {e}")
+
+ message = custom_message or str(e) or f"An error occurred in {func.__name__}"
+
+ return ResponseBuilder.error(
+ code=error_code,
+ message=message,
+ details={
+ "function": func.__name__,
+ "error_type": type(e).__name__,
+ "error_message": str(e)
+ }
+ )
+
+ @wraps(func)
+ def sync_wrapper(*args, **kwargs):
+ try:
+ result = func(*args, **kwargs)
+
+ if isinstance(result, APIResponse):
+ return result
+
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data=result if isinstance(result, (dict, list)) else {"result": result}
+ )
+
+ except Exception as e:
+ logger.exception(f"Error in {func.__name__}: {e}")
+
+ message = custom_message or str(e) or f"An error occurred in {func.__name__}"
+
+ return ResponseBuilder.error(
+ code=error_code,
+ message=message,
+ details={
+ "function": func.__name__,
+ "error_type": type(e).__name__,
+ "error_message": str(e)
+ }
+ )
+
+ import asyncio
+ if asyncio.iscoroutinefunction(func):
+ return async_wrapper
+ else:
+ return sync_wrapper
+
+ return decorator
+
+
+# ==================== 组合装饰器 ====================
+
+def api_endpoint(
+ use_timing: bool = True,
+ use_pagination: bool = False,
+ use_error_handling: bool = True,
+ **kwargs
+) -> Callable:
+ """组合API端点装饰器
+
+ 将多个装饰器组合在一起,提供完整的API功能。
+
+ 使用示例:
+ @api_endpoint(use_pagination=True, default_page_size=20)
+ async def list_items(page: int = 1, page_size: int = 20):
+ items = get_items(page, page_size)
+ total = count_items()
+ return items, total
+
+ Args:
+ use_timing: 是否使用自动计时
+ use_pagination: 是否使用自动分页
+ use_error_handling: 是否使用错误处理
+ **kwargs: 传递给各装饰器的参数
+ """
+
+ def decorator(func: Callable) -> Callable:
+ wrapped_func = func
+
+ # 按顺序应用装饰器(从里到外)
+ if use_error_handling:
+ error_kwargs = {k: v for k, v in kwargs.items() if k in ['error_code', 'custom_message']}
+ wrapped_func = handle_errors(**error_kwargs)(wrapped_func)
+
+ if use_pagination:
+ page_kwargs = {k: v for k, v in kwargs.items() if k in ['default_page_size', 'max_page_size', 'page_param', 'page_size_param']}
+ wrapped_func = paginated(**page_kwargs)(wrapped_func)
+
+ if use_timing:
+ wrapped_func = timed_response(wrapped_func)
+
+ return wrapped_func
+
+ return decorator
+
diff --git a/src/mcpstore/core/models/service.py b/src/mcpstore/core/models/service.py
index 54d23175..2db2254e 100644
--- a/src/mcpstore/core/models/service.py
+++ b/src/mcpstore/core/models/service.py
@@ -1,94 +1,134 @@
-from pydantic import BaseModel, Field
-from typing import Optional, List, Dict, Any, Literal, Union
-from enum import Enum
-from datetime import datetime
-
-class TransportType(str, Enum):
- STREAMABLE_HTTP = "streamable_http"
- STDIO = "stdio"
- STDIO_PYTHON = "stdio_python"
- STDIO_NODE = "stdio_node"
- STDIO_SHELL = "stdio_shell"
-
-class ServiceInfo(BaseModel):
- url: str = ""
- name: str
- transport_type: TransportType
- status: Literal["healthy", "unhealthy"]
- tool_count: int
- keep_alive: bool
- working_dir: Optional[str] = None
- env: Optional[Dict[str, str]] = None
- last_heartbeat: Optional[datetime] = None
- command: Optional[str] = None
- args: Optional[List[str]] = None
- package_name: Optional[str] = None
-
-class ServiceInfoResponse(BaseModel):
- """单个服务的详细信息响应模型"""
- service: ServiceInfo
- tools: List[Dict[str, Any]]
- connected: bool
-
-class ServicesResponse(BaseModel):
- services: List[ServiceInfo]
- total_services: int
- total_tools: int
-
-class RegisterRequestUnion(BaseModel):
- url: Optional[str] = None
- name: Optional[str] = None
- transport: Optional[str] = None
- keep_alive: Optional[bool] = None
- working_dir: Optional[str] = None
- env: Optional[Dict[str, str]] = None
- command: Optional[str] = None
- args: Optional[List[str]] = None
- package_name: Optional[str] = None
-
-class JsonUpdateRequest(BaseModel):
- client_id: Optional[str] = None
- service_names: Optional[List[str]] = None
- config: Dict[str, Any]
-
-class JsonRegistrationResponse(BaseModel):
- client_id: str
- service_names: List[str]
- config: Dict[str, Any]
-
-class JsonConfigResponse(BaseModel):
- client_id: str
- config: Dict[str, Any]
-
-class ServiceRegistrationResult(BaseModel):
- success: bool
- message: str
-
-class ServiceConfig(BaseModel):
- """服务配置基类"""
- name: str = Field(..., description="服务名称")
-
-class URLServiceConfig(ServiceConfig):
- """URL方式的服务配置"""
- url: str = Field(..., description="服务URL")
- transport: Optional[str] = Field("streamable-http", description="传输类型: streamable-http 或 sse")
- headers: Optional[Dict[str, str]] = Field(default=None, description="请求头")
-
-class CommandServiceConfig(ServiceConfig):
- """本地命令方式的服务配置"""
- command: str = Field(..., description="执行命令")
- args: Optional[List[str]] = Field(default=None, description="命令参数")
- env: Optional[Dict[str, str]] = Field(default=None, description="环境变量")
- working_dir: Optional[str] = Field(default=None, description="工作目录")
-
-class MCPServerConfig(BaseModel):
- """完整的MCP服务配置"""
- mcpServers: Dict[str, Dict[str, Any]] = Field(..., description="MCP服务配置字典")
-
-# 支持多种配置格式
-ServiceConfigUnion = Union[URLServiceConfig, CommandServiceConfig, MCPServerConfig, Dict[str, Any]]
-
-class AddServiceRequest(BaseModel):
- """添加服务请求"""
- config: ServiceConfigUnion = Field(..., description="服务配置,支持多种格式")
- update_config: bool = Field(default=True, description="是否更新配置文件")
+from datetime import datetime
+from enum import Enum
+from typing import Optional, List, Dict, Any, Union
+
+from pydantic import BaseModel, Field
+
+
+class TransportType(str, Enum):
+ STREAMABLE_HTTP = "streamable_http"
+ STDIO = "stdio"
+ STDIO_PYTHON = "stdio_python"
+ STDIO_NODE = "stdio_node"
+ STDIO_SHELL = "stdio_shell"
+
+
+class ServiceConnectionState(str, Enum):
+ """Service connection lifecycle state enumeration"""
+ INITIALIZING = "initializing" # Initializing: configuration validated, performing first connection
+ HEALTHY = "healthy" # Healthy: connection normal, heartbeat successful
+ WARNING = "warning" # Warning: occasional heartbeat failures, but not reaching reconnection threshold
+ RECONNECTING = "reconnecting" # Reconnecting: consecutive failures reached threshold, reconnecting
+ UNREACHABLE = "unreachable" # Unreachable: reconnection failed, entering long-cycle retry
+ DISCONNECTING = "disconnecting" # Disconnecting: performing graceful shutdown
+ DISCONNECTED = "disconnected" # Disconnected: service terminated, waiting for manual deletion
+
+class ServiceStateMetadata(BaseModel):
+ """Service state metadata"""
+ consecutive_failures: int = 0
+ consecutive_successes: int = 0
+ last_ping_time: Optional[datetime] = None
+ last_success_time: Optional[datetime] = None
+ last_failure_time: Optional[datetime] = None
+ response_time: Optional[float] = None
+ error_message: Optional[str] = None
+ # failure_reason 用于分类错误原因,例如 "auth_failed"、"network_error" 等
+ failure_reason: Optional[str] = None
+ reconnect_attempts: int = 0
+ next_retry_time: Optional[datetime] = None
+ state_entered_time: Optional[datetime] = None
+ disconnect_reason: Optional[str] = None
+ # 新增:服务配置信息
+ service_config: Dict[str, Any] = Field(default_factory=dict)
+ service_name: Optional[str] = None
+ agent_id: Optional[str] = None
+ # 修复:添加缺失的字段
+ last_health_check: Optional[datetime] = None
+ last_response_time: Optional[float] = None
+ # 工具同步相关:用于避免“健康但无工具”时的无限重试
+ tool_sync_attempts: int = 0
+ tools_confirmed_empty: bool = False
+ last_tool_sync: Optional[datetime] = None
+
+
+class ServiceInfo(BaseModel):
+ url: str = ""
+ name: str
+ transport_type: TransportType
+ status: ServiceConnectionState # Use new 7-state enumeration
+ tool_count: int
+ keep_alive: bool
+ working_dir: Optional[str] = None
+ env: Optional[Dict[str, str]] = None
+ last_heartbeat: Optional[datetime] = None
+ command: Optional[str] = None
+ args: Optional[List[str]] = None
+ package_name: Optional[str] = None
+ # New lifecycle-related fields
+ state_metadata: Optional[ServiceStateMetadata] = None
+ last_state_change: Optional[datetime] = None
+ client_id: Optional[str] = None # Add client_id field
+ config: Dict[str, Any] = Field(default_factory=dict) # [REFACTOR] 添加完整的config字段
+
+class ServiceInfoResponse(BaseModel):
+ """Detailed information response model for a single service"""
+ service: Optional[ServiceInfo] = Field(None, description="服务信息")
+ tools: List[Dict[str, Any]] = Field(..., description="服务提供的工具列表")
+ connected: bool = Field(..., description="服务连接状态")
+ success: bool = Field(True, description="操作是否成功")
+ message: Optional[str] = Field(None, description="响应消息")
+
+class ServicesResponse(BaseModel):
+ """Service list response model"""
+ services: List[ServiceInfo] = Field(..., description="服务列表")
+ total_services: int = Field(..., description="服务总数")
+ total_tools: int = Field(..., description="工具总数")
+ success: bool = Field(True, description="操作是否成功")
+ message: Optional[str] = Field(None, description="响应消息")
+
+class RegisterRequestUnion(BaseModel):
+ url: Optional[str] = None
+ name: Optional[str] = None
+ transport: Optional[str] = None
+ keep_alive: Optional[bool] = None
+ working_dir: Optional[str] = None
+ env: Optional[Dict[str, str]] = None
+ command: Optional[str] = None
+ args: Optional[List[str]] = None
+ package_name: Optional[str] = None
+
+class JsonUpdateRequest(BaseModel):
+ client_id: Optional[str] = None
+ service_names: Optional[List[str]] = None
+ config: Dict[str, Any]
+
+# These response models have been moved to common.py, please import directly from common.py
+
+class ServiceConfig(BaseModel):
+ """Service configuration base class"""
+ name: str = Field(..., description="服务名称")
+
+class URLServiceConfig(ServiceConfig):
+ """URL-based service configuration"""
+ url: str = Field(..., description="Service URL")
+ transport: Optional[str] = Field("streamable-http", description="Transport type: streamable-http or sse")
+ headers: Optional[Dict[str, str]] = Field(default=None, description="Request headers")
+
+class CommandServiceConfig(ServiceConfig):
+ """Local command-based service configuration"""
+ command: str = Field(..., description="Command to execute")
+ args: Optional[List[str]] = Field(default=None, description="Command arguments")
+ env: Optional[Dict[str, str]] = Field(default=None, description="Environment variables")
+ working_dir: Optional[str] = Field(default=None, description="Working directory")
+
+class MCPServerConfig(BaseModel):
+ """Complete MCP service configuration"""
+ mcpServers: Dict[str, Dict[str, Any]] = Field(..., description="MCP service configuration dictionary")
+
+# Support multiple configuration formats
+ServiceConfigUnion = Union[URLServiceConfig, CommandServiceConfig, MCPServerConfig, Dict[str, Any]]
+
+class AddServiceRequest(BaseModel):
+ """Add service request"""
+ config: ServiceConfigUnion = Field(..., description="Service configuration, supports multiple formats")
+ update_config: bool = Field(default=True, description="Whether to update configuration file")
diff --git a/src/mcpstore/core/models/tool.py b/src/mcpstore/core/models/tool.py
index 2696f0ae..e7102ac3 100644
--- a/src/mcpstore/core/models/tool.py
+++ b/src/mcpstore/core/models/tool.py
@@ -1,24 +1,41 @@
-from pydantic import BaseModel
from typing import Optional, List, Dict, Any
+from pydantic import BaseModel, Field
+
+
class ToolInfo(BaseModel):
+ # 工具全局名称(L3,用于内部索引/调用)
name: str
- description: str
+ # FastMCP 标准格式名称(L2,用于调用 FastMCP)
+ tool_original_name: str
+ # 服务原始名称(L0/FastMCP 视角)
+ service_original_name: str
+ # 服务全局名称(L3,用于内部索引)
+ service_global_name: str
+ # 为兼容既有接口,service_name 保持与原始名称一致
service_name: str
+ description: str
client_id: Optional[str] = None
inputSchema: Optional[Dict[str, Any]] = None
-
-class ToolsResponse(BaseModel):
- tools: List[ToolInfo]
- total_tools: int
-
-class ToolExecutionRequest(BaseModel):
- tool_name: str
- args: Dict[str, Any]
- agent_id: Optional[str] = None
- client_id: Optional[str] = None
-
-class ToolExecutionResponse(BaseModel):
- success: bool
- result: Any
- error: Optional[str] = None
+
+class ToolsResponse(BaseModel):
+ """Tool list response model"""
+ tools: List[ToolInfo] = Field(..., description="Tool list")
+ total_tools: int = Field(..., description="Total number of tools")
+ success: bool = Field(True, description="Whether operation was successful")
+ message: Optional[str] = Field(None, description="Response message")
+
+class ToolExecutionRequest(BaseModel):
+ tool_name: str = Field(..., description="Tool name (FastMCP original name)")
+ service_name: str = Field(..., description="Service name")
+ args: Dict[str, Any] = Field(default_factory=dict, description="Tool parameters")
+ agent_id: Optional[str] = Field(None, description="Agent ID")
+ client_id: Optional[str] = Field(None, description="Client ID")
+ session_id: Optional[str] = Field(None, description="Session ID (for session-aware execution)")
+
+ # FastMCP standard parameters
+ timeout: Optional[float] = Field(None, description="Timeout (seconds)")
+ progress_handler: Optional[Any] = Field(None, description="Progress handler")
+ raise_on_error: bool = Field(True, description="Whether to raise exception on error")
+
+# ToolExecutionResponse has been moved to common.py, please import directly from common.py
diff --git a/src/mcpstore/core/models/tool_result.py b/src/mcpstore/core/models/tool_result.py
new file mode 100644
index 00000000..3575baf0
--- /dev/null
+++ b/src/mcpstore/core/models/tool_result.py
@@ -0,0 +1,54 @@
+"""
+CallToolResult 辅助模型
+
+提供与 FastMCP 官方 `CallToolResult` 完全兼容的失败结果封装,保证调用链
+无论成功还是失败都能拿到统一的数据结构。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Optional, Any
+
+from mcp import types as mcp_types
+
+
+@dataclass
+class CallToolFailureResult:
+ """
+ FastMCP CallToolResult 的失败封装。
+
+ 通过标准的文本内容块返回错误信息,同时补齐 FastMCP 客户端常用的
+ `structured_content`、`data`、`error`、`is_error` 等字段,便于调用方直接
+ 当作官方结果使用。
+ """
+
+ message: str
+ cause: Optional[Any] = None
+ _result: mcp_types.CallToolResult = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ text_block = mcp_types.TextContent(type="text", text=self.message)
+ failure = mcp_types.CallToolResult(
+ content=[text_block],
+ structuredContent=None,
+ isError=True,
+ )
+ # FastMCP 官方对象同时会暴露蛇形和驼峰字段,这里补齐常用别名
+ setattr(failure, "structured_content", None)
+ setattr(failure, "data", None)
+ setattr(failure, "error", self.message)
+ setattr(failure, "is_error", True)
+ if self.cause is not None:
+ setattr(failure, "cause", str(self.cause))
+ self._result = failure
+
+ def unwrap(self) -> mcp_types.CallToolResult:
+ """返回 FastMCP 官方 CallToolResult 对象。"""
+ return self._result
+
+ def __getattr__(self, item: str) -> Any:
+ return getattr(self._result, item)
+
+ def __repr__(self) -> str:
+ return f"CallToolFailureResult(message={self.message!r})"
diff --git a/src/mcpstore/core/models/tool_set.py b/src/mcpstore/core/models/tool_set.py
new file mode 100644
index 00000000..0d9a44e0
--- /dev/null
+++ b/src/mcpstore/core/models/tool_set.py
@@ -0,0 +1,143 @@
+"""
+工具集状态数据模型
+
+本模块定义了工具集管理系统的核心数据模型。
+"""
+
+import time
+from dataclasses import dataclass, field
+from typing import Set, Dict, Any, List
+
+
+@dataclass
+class ToolSetState:
+ """
+ Agent 服务的工具集状态
+
+ 表示某个 Agent 对某个服务的工具集管理状态,包括当前可用的工具集合、
+ 操作历史等信息。
+
+ Attributes:
+ agent_id: Agent 的唯一标识符
+ service_name: 服务名称(Agent 本地名称)
+ available_tools: 当前可用的工具名称集合
+ created_at: 创建时间戳
+ updated_at: 最后更新时间戳
+ version: 版本号,用于并发控制
+ operation_history: 操作历史记录列表(最多保留10条)
+ """
+
+ agent_id: str
+ service_name: str
+ available_tools: Set[str] = field(default_factory=set)
+
+ # 元数据
+ created_at: float = field(default_factory=time.time)
+ updated_at: float = field(default_factory=time.time)
+ version: int = 1
+
+ # 操作历史(可选)
+ operation_history: List[Dict[str, Any]] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """
+ 序列化为字典
+
+ 将 ToolSetState 对象转换为可以存储到缓存的字典格式。
+
+ Returns:
+ 包含所有状态信息的字典
+ """
+ return {
+ "agent_id": self.agent_id,
+ "service_name": self.service_name,
+ "available_tools": list(self.available_tools),
+ "created_at": self.created_at,
+ "updated_at": self.updated_at,
+ "version": self.version,
+ "operation_history": self.operation_history[-10:] # 只保留最近10条
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'ToolSetState':
+ """
+ 从字典反序列化
+
+ 从缓存中读取的字典数据创建 ToolSetState 对象。
+
+ Args:
+ data: 包含状态信息的字典
+
+ Returns:
+ ToolSetState 对象实例
+ """
+ return cls(
+ agent_id=data["agent_id"],
+ service_name=data["service_name"],
+ available_tools=set(data.get("available_tools", [])),
+ created_at=data.get("created_at", time.time()),
+ updated_at=data.get("updated_at", time.time()),
+ version=data.get("version", 1),
+ operation_history=data.get("operation_history", [])
+ )
+
+ def add_tools(self, tool_names: Set[str]) -> None:
+ """
+ 添加工具到可用集合
+
+ 将指定的工具添加到当前可用工具集合中。这是一个增量操作,
+ 不会影响已存在的工具。
+
+ Args:
+ tool_names: 要添加的工具名称集合
+ """
+ self.available_tools.update(tool_names)
+ self.updated_at = time.time()
+ self.version += 1
+ self._record_operation("add", list(tool_names))
+
+ def remove_tools(self, tool_names: Set[str]) -> None:
+ """
+ 从可用集合移除工具
+
+ 将指定的工具从当前可用工具集合中移除。如果工具不存在,
+ 不会产生错误。
+
+ Args:
+ tool_names: 要移除的工具名称集合
+ """
+ self.available_tools.difference_update(tool_names)
+ self.updated_at = time.time()
+ self.version += 1
+ self._record_operation("remove", list(tool_names))
+
+ def reset(self, all_tools: Set[str]) -> None:
+ """
+ 重置为所有工具
+
+ 将可用工具集合重置为指定的完整工具集。通常用于恢复到
+ 服务的默认状态(所有工具可用)。
+
+ Args:
+ all_tools: 完整的工具名称集合
+ """
+ self.available_tools = all_tools.copy()
+ self.updated_at = time.time()
+ self.version += 1
+ self._record_operation("reset", [])
+
+ def _record_operation(self, op_type: str, tools: List[str]) -> None:
+ """
+ 记录操作历史
+
+ 将操作记录添加到历史列表中,用于审计和调试。
+
+ Args:
+ op_type: 操作类型("add", "remove", "reset")
+ tools: 涉及的工具列表
+ """
+ self.operation_history.append({
+ "type": op_type,
+ "tools": tools,
+ "timestamp": time.time()
+ })
diff --git a/src/mcpstore/core/orchestrator.py b/src/mcpstore/core/orchestrator.py
index eac52b49..e4bdedf9 100644
--- a/src/mcpstore/core/orchestrator.py
+++ b/src/mcpstore/core/orchestrator.py
@@ -1,711 +1,3 @@
-import os, sys
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from .orchestrator import MCPOrchestrator
-"""
-MCP服务编排器
-
-该模块提供了MCPOrchestrator类,用于管理MCP服务的连接、工具调用和查询处理。
-它是FastAPI应用程序的核心组件,负责协调客户端和服务之间的交互。
-"""
-
-import asyncio
-import logging
-from typing import Dict, List, Any, Optional, Tuple, Set, Union, AsyncGenerator
-from datetime import datetime, timedelta
-from urllib.parse import urljoin
-
-from mcpstore.core.registry import ServiceRegistry
-from mcpstore.core.client_manager import ClientManager
-from fastmcp import Client
-from fastmcp.client.transports import (
- MCPConfigTransport,
- StreamableHttpTransport,
- SSETransport,
- PythonStdioTransport,
- NodeStdioTransport,
- UvxStdioTransport,
- NpxStdioTransport
-)
-from mcpstore.plugins.json_mcp import MCPConfig
-from mcpstore.core.models.service import TransportType, ServiceRegistrationResult
-from mcpstore.core.session_manager import SessionManager
-
-logger = logging.getLogger(__name__)
-
-class MCPOrchestrator:
- """
- MCP服务编排器
-
- 负责管理服务连接、工具调用和查询处理。
- """
-
- def __init__(self, config: Dict[str, Any], registry: ServiceRegistry):
- """
- 初始化MCP编排器
-
- Args:
- config: 配置字典
- registry: 服务注册表实例
- """
- self.config = config
- self.registry = registry
- self.clients: Dict[str, Client] = {} # key为mcpServers的服务名
- self.main_client: Optional[Client] = None
- self.main_client_ctx = None # async context manager for main_client
- self.main_config = {"mcpServers": {}} # 中央配置
- self.agent_clients: Dict[str, Client] = {} # agent_id -> client映射
- self.pending_reconnection: Set[str] = set()
- self.react_agent = None
-
- # 从配置中获取心跳和重连设置
- timing_config = config.get("timing", {})
- self.heartbeat_interval = timedelta(seconds=int(timing_config.get("heartbeat_interval_seconds", 60)))
- self.heartbeat_timeout = timedelta(seconds=int(timing_config.get("heartbeat_timeout_seconds", 180)))
- self.reconnection_interval = timedelta(seconds=int(timing_config.get("reconnection_interval_seconds", 60)))
- self.http_timeout = int(timing_config.get("http_timeout_seconds", 10))
-
- # 监控任务
- self.heartbeat_task = None
- self.reconnection_task = None
- self.mcp_config = MCPConfig()
-
- # 客户端管理器
- self.client_manager = ClientManager()
-
- # 会话管理器
- self.session_manager = SessionManager()
-
- async def setup(self):
- """初始化编排器资源(不再做服务注册)"""
- logger.info("Setting up MCP Orchestrator...")
- # 只做必要的资源初始化
- pass
-
- async def start_monitoring(self):
- """启动后台健康检查和重连监视器"""
- logger.info("Starting monitoring tasks...")
-
- # 启动心跳监视器
- if self.heartbeat_task is None or self.heartbeat_task.done():
- logger.info(f"Starting heartbeat monitor. Interval: {self.heartbeat_interval.total_seconds()}s")
- self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
-
- # 启动重连监视器
- if self.reconnection_task is None or self.reconnection_task.done():
- logger.info(f"Starting reconnection monitor. Interval: {self.reconnection_interval.total_seconds()}s")
- self.reconnection_task = asyncio.create_task(self._reconnection_loop())
-
- async def _heartbeat_loop(self):
- """后台循环,用于定期健康检查"""
- while True:
- await asyncio.sleep(self.heartbeat_interval.total_seconds())
- await self._check_services_health()
-
- async def _check_services_health(self):
- """检查所有服务的健康状态"""
- logger.debug("Running periodic health check for all services...")
- for client_id, services in self.registry.sessions.items():
- for name in services:
- try:
- is_healthy = await self.is_service_healthy(name, client_id)
- if is_healthy:
- logger.debug(f"Health check SUCCESS for: {name} (client_id={client_id})")
- self.registry.update_service_health(client_id, name)
- else:
- logger.warning(f"Health check FAILED for {name} (client_id={client_id})")
- self.pending_reconnection.add(name)
- except Exception as e:
- logger.warning(f"Health check error for {name} (client_id={client_id}): {e}")
- self.pending_reconnection.add(name)
-
- async def _reconnection_loop(self):
- """定期尝试重新连接服务的后台循环"""
- while True:
- await asyncio.sleep(self.reconnection_interval.total_seconds())
- await self._attempt_reconnections()
-
- async def _attempt_reconnections(self):
- """尝试重新连接所有待重连的服务"""
- if not self.pending_reconnection:
- return # 如果没有待重连的服务,跳过
-
- # 创建副本以避免迭代过程中修改集合的问题
- names_to_retry = list(self.pending_reconnection)
- logger.info(f"Attempting to reconnect {len(names_to_retry)} service(s): {names_to_retry}")
-
- for name in names_to_retry:
- try:
- # 尝试重新连接
- success, message = await self.connect_service(name)
- if success:
- logger.info(f"Reconnection successful for: {name}")
- self.pending_reconnection.discard(name)
- else:
- logger.warning(f"Reconnection attempt failed for {name}: {message}")
- # 保持name在pending_reconnection中,等待下一个周期
- except Exception as e:
- logger.warning(f"Reconnection attempt failed for {name}: {e}")
-
- async def connect_service(self, name: str, url: str = None) -> Tuple[bool, str]:
- """
- 连接到指定的服务
-
- Args:
- name: 服务名称
- url: 服务URL(可选,如果不提供则从配置中获取)
-
- Returns:
- Tuple[bool, str]: (是否成功, 消息)
- """
- try:
- # 获取服务配置
- service_config = self.mcp_config.get_service_config(name)
- if not service_config:
- return False, f"Service configuration not found for {name}"
-
- # 如果提供了URL,更新配置
- if url:
- service_config["url"] = url
-
- # 创建新的客户端
- client = Client({"mcpServers": {name: service_config}})
-
- # 尝试连接
- try:
- await client.list_tools()
- self.clients[name] = client
- logger.info(f"Service {name} connected successfully")
- return True, "Connected successfully"
- except Exception as e:
- logger.error(f"Failed to connect to service {name}: {e}")
- return False, str(e)
-
- except Exception as e:
- logger.error(f"Failed to connect service {name}: {e}")
- return False, str(e)
-
- async def disconnect_service(self, url_or_name: str) -> bool:
- """从配置中移除服务并更新main_client"""
- logger.info(f"Removing service: {url_or_name}")
-
- # 查找要移除的服务名
- name_to_remove = None
- for name, server in self.main_config.get("mcpServers", {}).items():
- if name == url_or_name or server.get("url") == url_or_name:
- name_to_remove = name
- break
-
- if name_to_remove:
- # 从main_config中移除
- if name_to_remove in self.main_config["mcpServers"]:
- del self.main_config["mcpServers"][name_to_remove]
-
- # 从配置文件中移除
- ok = self.mcp_config.remove_service(name_to_remove)
- if not ok:
- logger.warning(f"Failed to remove service {name_to_remove} from configuration file")
-
- # 从registry中移除
- self.registry.remove_service(name_to_remove)
-
- # 重新创建main_client
- if self.main_config.get("mcpServers"):
- self.main_client = Client(self.main_config)
-
- # 更新所有agent_clients
- for agent_id in list(self.agent_clients.keys()):
- self.agent_clients[agent_id] = Client(self.main_config)
- logger.info(f"Updated client for agent {agent_id} after removing service")
-
- else:
- # 如果没有服务了,清除main_client
- self.main_client = None
- # 清除所有agent_clients
- self.agent_clients.clear()
-
- return True
- else:
- logger.warning(f"Service {url_or_name} not found in configuration.")
- return False
-
- async def refresh_services(self):
- """手动刷新所有服务连接(重新加载mcp.json)"""
- await self.load_from_config()
-
- async def is_service_healthy(self, name: str, client_id: Optional[str] = None) -> bool:
- """
- 检查服务是否健康
-
- Args:
- name: 服务名
- client_id: 可选的客户端ID,用于多客户端环境
-
- Returns:
- bool: 服务是否健康
- """
- try:
- # 获取服务配置
- service_config = self.mcp_config.get_service_config(name)
- if not service_config:
- logger.warning(f"Service configuration not found for {name}")
- return False
-
- # 创建新的客户端实例
- client = Client({"mcpServers": {name: service_config}})
-
- try:
- # 使用超时控制的异步上下文管理器
- async with asyncio.timeout(self.http_timeout):
- async with client:
- await client.ping()
- return True
- except asyncio.TimeoutError:
- logger.warning(f"Health check timeout for {name} (client_id={client_id})")
- return False
- except Exception as e:
- logger.warning(f"Health check failed for {name} (client_id={client_id}): {e}")
- return False
- finally:
- # 确保客户端被正确关闭
- try:
- await client.close()
- except Exception:
- pass # 忽略关闭时的错误
-
- except Exception as e:
- logger.warning(f"Health check failed for {name} (client_id={client_id}): {e}")
- return False
-
- # async def process_unified_query(
- # self,
- # query: str,
- # agent_id: Optional[str] = None,
- # mode: str = "react",
- # include_trace: bool = False
- # ) -> Union[str, Dict[str, Any]]:
- # """处理统一查询"""
- # # 获取或创建会话
- # session = self.session_manager.get_or_create_session(agent_id)
- #
- # if not session.tools:
- # # 如果会话没有工具,加载所有可用工具
- # for service_name, client in self.clients.items():
- # try:
- # tools = await client.list_tools()
- # for tool in tools:
- # session.add_tool(tool.name, {
- # "name": tool.name,
- # "description": tool.description,
- # "inputSchema": tool.inputSchema if hasattr(tool, "inputSchema") else None
- # }, service_name)
- # session.add_service(service_name, client)
- # except Exception as e:
- # logger.error(f"Failed to load tools from service {service_name}: {e}")
- #
- # # 处理查询...
- # return {"result": "query processed", "session_id": session.agent_id}
-
- async def execute_tool(
- self,
- service_name: str,
- tool_name: str,
- parameters: Dict[str, Any],
- agent_id: Optional[str] = None
- ) -> Any:
- """执行工具"""
- try:
- if agent_id:
- # agent模式:在agent的所有client中查找服务
- client_ids = self.client_manager.get_agent_clients(agent_id)
- if not client_ids:
- raise Exception(f"No clients found for agent {agent_id}")
-
- # 在所有client中查找服务
- for client_id in client_ids:
- if self.registry.has_service(client_id, service_name):
- # 获取服务配置
- service_config = self.mcp_config.get_service_config(service_name)
- if not service_config:
- logger.warning(f"Service configuration not found for {service_name}")
- continue
-
- logger.debug(f"Creating new client for service {service_name} with config: {service_config}")
- # 创建新的客户端实例
- client = Client({"mcpServers": {service_name: service_config}})
- try:
- async with client:
- logger.debug(f"Client connected: {client.is_connected()}")
-
- # 获取工具列表并打印
- tools = await client.list_tools()
- logger.debug(f"Available tools for service {service_name}: {[t.name for t in tools]}")
-
- # 检查工具名称格式
- base_tool_name = tool_name
- if tool_name.startswith(f"{service_name}_"):
- base_tool_name = tool_name[len(service_name)+1:]
- logger.debug(f"Using base tool name: {base_tool_name}")
-
- # 检查工具是否存在
- if not any(t.name == base_tool_name for t in tools):
- logger.warning(f"Tool {base_tool_name} not found in available tools")
- continue
-
- # 执行工具
- logger.debug(f"Calling tool {base_tool_name} with parameters: {parameters}")
- result = await client.call_tool(base_tool_name, parameters)
- logger.info(f"Tool {base_tool_name} executed successfully with client {client_id}")
- return result
- except Exception as e:
- logger.error(f"Failed to execute tool with client {client_id}: {e}")
- continue
-
- raise Exception(f"Service {service_name} not found in any client for agent {agent_id}")
- else:
- # store模式:在main_client的所有client中查找服务
- client_ids = self.client_manager.get_agent_clients(self.client_manager.main_client_id)
- if not client_ids:
- raise Exception("No clients found in main_client")
-
- # 在所有client中查找服务
- for client_id in client_ids:
- if self.registry.has_service(client_id, service_name):
- # 获取服务配置
- service_config = self.mcp_config.get_service_config(service_name)
- if not service_config:
- logger.warning(f"Service configuration not found for {service_name}")
- continue
-
- logger.debug(f"Creating new client for service {service_name} with config: {service_config}")
- # 创建新的客户端实例
- client = Client({"mcpServers": {service_name: service_config}})
- try:
- async with client:
- logger.debug(f"Client connected: {client.is_connected()}")
-
- # 获取工具列表并打印
- tools = await client.list_tools()
- logger.debug(f"Available tools for service {service_name}: {[t.name for t in tools]}")
-
- # 检查工具名称格式
- base_tool_name = tool_name
- if tool_name.startswith(f"{service_name}_"):
- base_tool_name = tool_name[len(service_name)+1:]
- logger.debug(f"Using base tool name: {base_tool_name}")
-
- # 检查工具是否存在
- if not any(t.name == base_tool_name for t in tools):
- logger.warning(f"Tool {base_tool_name} not found in available tools")
- continue
-
- # 执行工具
- logger.debug(f"Calling tool {base_tool_name} with parameters: {parameters}")
- result = await client.call_tool(base_tool_name, parameters)
- logger.info(f"Tool {base_tool_name} executed successfully with client {client_id}")
- return result
- except Exception as e:
- logger.error(f"Failed to execute tool with client {client_id}: {e}")
- continue
-
- raise Exception(f"Tool not found: {tool_name}")
- except Exception as e:
- logger.error(f"Tool execution failed: {e}")
- raise Exception(f"Tool execution failed: {str(e)}")
-
- async def cleanup(self):
- """清理资源"""
- logger.info("Cleaning up MCP Orchestrator resources...")
-
- # 清理会话
- self.session_manager.cleanup_expired_sessions()
-
- # 停止监控任务
- if self.heartbeat_task and not self.heartbeat_task.done():
- self.heartbeat_task.cancel()
- try:
- await self.heartbeat_task
- except asyncio.CancelledError:
- pass
-
- if self.reconnection_task and not self.reconnection_task.done():
- self.reconnection_task.cancel()
- try:
- await self.reconnection_task
- except asyncio.CancelledError:
- pass
-
- # 关闭所有客户端连接
- for name, client in self.clients.items():
- try:
- await client.close()
- except Exception as e:
- logger.error(f"Error closing client {name}: {e}")
-
- self.clients.clear()
- self.pending_reconnection.clear()
-
- async def register_agent_client(self, agent_id: str, config: Optional[Dict[str, Any]] = None) -> Client:
- """
- 为agent注册一个新的client实例
-
- Args:
- agent_id: 代理ID
- config: 可选的配置,如果为None则使用main_config
-
- Returns:
- 新创建的Client实例
- """
- # 使用main_config或提供的config创建新的client
- agent_config = config or self.main_config
- agent_client = Client(agent_config)
-
- # 存储agent_client
- self.agent_clients[agent_id] = agent_client
- logger.info(f"Registered agent client for {agent_id}")
-
- return agent_client
-
- def get_agent_client(self, agent_id: str) -> Optional[Client]:
- """
- 获取agent的client实例
-
- Args:
- agent_id: 代理ID
-
- Returns:
- Client实例或None
- """
- return self.agent_clients.get(agent_id)
-
- async def filter_healthy_services(self, services: List[str], client_id: Optional[str] = None) -> List[str]:
- """
- 过滤出健康的服务列表
-
- Args:
- services: 服务名列表
- client_id: 可选的客户端ID,用于多客户端环境
-
- Returns:
- List[str]: 健康的服务名列表
- """
- healthy_services = []
- for name in services:
- try:
- service_config = self.mcp_config.get_service_config(name)
- if not service_config:
- logger.warning(f"Service configuration not found for {name}")
- continue
-
- # 创建新的客户端实例
- client = Client({"mcpServers": {name: service_config}})
-
- try:
- # 使用超时控制的异步上下文管理器
- async with asyncio.timeout(self.http_timeout):
- async with client:
- await client.ping()
- healthy_services.append(name)
- except asyncio.TimeoutError:
- logger.warning(f"Health check timeout for {name} (client_id={client_id})")
- continue
- except Exception as e:
- logger.warning(f"Health check failed for {name} (client_id={client_id}): {e}")
- continue
- finally:
- # 确保客户端被正确关闭
- try:
- await client.close()
- except Exception:
- pass # 忽略关闭时的错误
-
- except Exception as e:
- logger.warning(f"Health check failed for {name} (client_id={client_id}): {e}")
- continue
-
- return healthy_services
-
- async def start_main_client(self, config: Dict[str, Any]):
- """启动 main_client 的 async with 生命周期,注册服务和工具(仅健康服务)"""
- # 获取健康的服务列表
- healthy_services = await self.filter_healthy_services(list(config.get("mcpServers", {}).keys()))
-
- # 创建一个新的配置,只包含健康的服务
- healthy_config = {
- "mcpServers": {
- name: config["mcpServers"][name]
- for name in healthy_services
- }
- }
-
- # 使用健康的配置注册服务
- await self.register_json_services(healthy_config, client_id="main_client")
- # main_client专属管理逻辑可在这里补充(如缓存、生命周期等)
-
- async def register_json_services(self, config: Dict[str, Any], client_id: str = None, agent_id: str = None):
- """注册JSON配置中的服务(可用于main_client或普通client)"""
- # agent_id 兼容
- agent_key = agent_id or client_id or self.client_manager.main_client_id
- try:
- # 获取健康的服务列表
- healthy_services = await self.filter_healthy_services(list(config.get("mcpServers", {}).keys()), client_id)
-
- if not healthy_services:
- logger.warning("No healthy services found")
- return {
- "client_id": client_id or "main_client",
- "services": {},
- "total_success": 0,
- "total_failed": 0
- }
-
- # 使用healthy_services构建新的配置
- healthy_config = {
- "mcpServers": {
- name: config["mcpServers"][name]
- for name in healthy_services
- }
- }
-
- # 使用健康的配置创建客户端
- client = Client(healthy_config)
-
- try:
- async with client:
- # 获取工具列表
- tool_list = await client.list_tools()
- if not tool_list:
- logger.warning("No tools found")
- return {
- "client_id": client_id or "main_client",
- "services": {},
- "total_success": 0,
- "total_failed": 0
- }
-
- # 处理工具列表
- all_tools = []
-
- # 判断是否是单服务情况
- is_single_service = len(healthy_services) == 1
-
- for tool in tool_list:
- tool_name = tool.name
-
- # 确定工具所属的服务
- if is_single_service:
- # 单服务情况:所有工具都属于这个服务
- service_name = healthy_services[0]
- # 如果工具名称还没有服务前缀,添加前缀
- if not tool_name.startswith(f"{service_name}_"):
- tool_name = f"{service_name}_{tool_name}"
- else:
- # 多服务情况:根据工具名称前缀判断
- service_name = None
- for name in healthy_services:
- if tool_name.startswith(f"{name}_"):
- service_name = name
- break
-
- if not service_name:
- logger.warning(f"Tool {tool_name} does not belong to any service, skipping")
- continue
-
- # 处理参数信息
- parameters = {}
- if hasattr(tool, 'inputSchema') and tool.inputSchema:
- parameters = tool.inputSchema
- elif hasattr(tool, 'parameters') and tool.parameters:
- parameters = tool.parameters
-
- tool_def = {
- "type": "function",
- "function": {
- "name": tool_name, # 使用可能被修改过的tool_name
- "description": tool.description,
- "parameters": parameters
- }
- }
- all_tools.append((tool_name, tool_def)) # 使用可能被修改过的tool_name
-
- # 为每个服务注册其工具
- for service_name in healthy_services:
- if is_single_service:
- service_tools = all_tools
- else:
- service_tools = [(name, tool_def) for name, tool_def in all_tools if name.startswith(f"{service_name}_")]
- logger.info(f"Filtered {len(service_tools)} tools for service {service_name}")
- self.registry.add_service(agent_key, service_name, client, service_tools)
- self.clients[service_name] = client
-
- return {
- "client_id": client_id or "main_client",
- "services": {
- name: {"status": "success", "message": "Service registered successfully"}
- for name in healthy_services
- },
- "total_success": len(healthy_services),
- "total_failed": 0
- }
- except Exception as e:
- logger.error(f"Error retrieving tools: {e}", exc_info=True)
- return {
- "client_id": client_id or "main_client",
- "services": {},
- "total_success": 0,
- "total_failed": 1,
- "error": str(e)
- }
- except Exception as e:
- logger.error(f"Error registering services: {e}", exc_info=True)
- return {
- "client_id": client_id or "main_client",
- "services": {},
- "total_success": 0,
- "total_failed": 1,
- "error": str(e)
- }
-
- def create_client_config_from_names(self, service_names: list) -> Dict[str, Any]:
- """
- 根据服务名列表,从 mcp.json 生成新的 client config
- """
- all_services = self.mcp_config.load_config().get("mcpServers", {})
- selected = {name: all_services[name] for name in service_names if name in all_services}
- return {"mcpServers": selected}
-
- def remove_service(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- self.registry.remove_service(agent_key, service_name)
- # ...其余逻辑...
-
- def get_session(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_session(agent_key, service_name)
-
- def get_tools_for_service(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_tools_for_service(agent_key, service_name)
-
- def get_all_service_names(self, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_all_service_names(agent_key)
-
- def get_all_tool_info(self, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_all_tool_info(agent_key)
-
- def get_service_details(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_service_details(agent_key, service_name)
-
- def update_service_health(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- self.registry.update_service_health(agent_key, service_name)
-
- def get_last_heartbeat(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.get_last_heartbeat(agent_key, service_name)
-
- def has_service(self, service_name: str, agent_id: str = None):
- agent_key = agent_id or self.client_manager.main_client_id
- return self.registry.has_service(agent_key, service_name)
+__all__ = ['MCPOrchestrator']
diff --git a/src/mcpstore/core/orchestrator/__init__.py b/src/mcpstore/core/orchestrator/__init__.py
new file mode 100644
index 00000000..b1c9ca02
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/__init__.py
@@ -0,0 +1,25 @@
+"""
+MCPOrchestrator Package
+Orchestrator package - Modularized refactored MCP service orchestrator
+
+This package refactors the original 2056-line orchestrator.py into 8 specialized modules:
+- base_orchestrator.py: Core infrastructure and lifecycle management (12 methods)
+- monitoring_tasks.py: Monitoring tasks and loop management (12 methods)
+- service_connection.py: Service connection and state management (15 methods)
+- tool_execution.py: Tool execution and processing (4 methods)
+- service_management.py: Service management and information retrieval (15 methods)
+- resources_prompts.py: Resources/Prompts functionality (12 methods)
+- network_utils.py: Network utilities and error handling (2 methods)
+- standalone_config.py: Standalone configuration adapter (6 methods)
+
+Total of 78 methods, fully maintaining backward compatibility.
+"""
+
+from .base_orchestrator import MCPOrchestrator
+
+# Export main classes
+__all__ = ['MCPOrchestrator']
+
+# Version information
+__version__ = "0.8.1"
+__description__ = "Modular MCP Service Orchestrator"
diff --git a/src/mcpstore/core/orchestrator/base_orchestrator.py b/src/mcpstore/core/orchestrator/base_orchestrator.py
new file mode 100644
index 00000000..a1b67986
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/base_orchestrator.py
@@ -0,0 +1,305 @@
+"""
+MCPOrchestrator Base Module
+Orchestrator core base module - contains infrastructure and lifecycle management
+"""
+
+import logging
+import time
+from typing import Dict, Any, Optional
+
+from fastmcp import Client
+
+from mcpstore.config.json_config import MCPConfig
+from mcpstore.core.agents.session_manager import SessionManager
+from mcpstore.core.integration.local_service_adapter import get_local_service_manager
+from mcpstore.core.registry import ServiceRegistry
+from mcpstore.core.store.client_manager import ClientManager
+# Import mixin classes
+from .network_utils import NetworkUtilsMixin
+from .resources_prompts import ResourcesPromptsMixin
+from .service_connection import ServiceConnectionMixin
+from .service_management import ServiceManagementMixin
+from .standalone_config import StandaloneConfigMixin
+from .tool_execution import ToolExecutionMixin
+
+logger = logging.getLogger(__name__)
+
+class MCPOrchestrator(
+ ServiceConnectionMixin,
+ ToolExecutionMixin,
+ ServiceManagementMixin,
+ ResourcesPromptsMixin,
+ NetworkUtilsMixin,
+ StandaloneConfigMixin
+):
+ """
+ MCP服务编排器
+
+ 负责管理服务连接、工具调用和查询处理。
+ """
+
+ def __init__(self, config: Dict[str, Any], registry: ServiceRegistry, standalone_config_manager=None, client_services_path=None, agent_clients_path=None, mcp_config=None):
+ """
+ 初始化MCP编排器
+
+ Args:
+ config: 配置字典
+ registry: 服务注册表实例
+ standalone_config_manager: 独立配置管理器(可选)
+ client_services_path: 客户端服务配置文件路径(可选,用于数据空间)
+ agent_clients_path: Agent客户端映射文件路径(可选,用于数据空间)
+ mcp_config: MCPConfig实例(可选,用于数据空间)
+ """
+ self.config = config
+ self.registry = registry
+ self.clients: Dict[str, Client] = {} # key为mcpServers的服务名
+ self.global_agent_store: Optional[Client] = None
+ self.global_agent_store_ctx = None # async context manager for global_agent_store
+ self.global_agent_store_config = {"mcpServers": {}} # 中央配置
+ self.agent_clients: Dict[str, Client] = {} # agent_id -> client映射
+ # 智能重连功能已集成到ServiceLifecycleManager中
+ self.react_agent = None
+
+ # 新增:独立配置管理器
+ self.standalone_config_manager = standalone_config_manager
+
+ # 新增:统一同步管理器
+ self.sync_manager = None
+
+ # 新增:ServiceContainer引用(替代 store 引用,解除循环依赖)
+ self.container = None
+
+ # 新增:Context工厂函数(用于服务注册,替代 store.for_store())
+ self._context_factory = None
+
+ # 旧的心跳和重连配置已被ServiceLifecycleManager替代
+ timing_config = config.get("timing", {})
+ # 保留http_timeout,其他配置已废弃
+ self.http_timeout = int(timing_config.get("http_timeout_seconds", 10))
+
+ # 监控任务已集成到ServiceLifecycleManager和ServiceContentManager中
+
+ # 修改:根据是否有独立配置管理器或传入的mcp_config决定如何初始化MCPConfig
+ if standalone_config_manager:
+ # 使用独立配置,不依赖文件系统
+ self.mcp_config = self._create_standalone_mcp_config(standalone_config_manager)
+ elif mcp_config:
+ # 使用传入的MCPConfig实例(用于数据空间)
+ self.mcp_config = mcp_config
+ else:
+ # 使用传统配置
+ self.mcp_config = MCPConfig()
+
+ # 旧的资源管理配置已被ServiceLifecycleManager替代
+ # 保留一些配置以避免错误,但实际不再使用
+
+ # 单一数据源架构:简化客户端管理器初始化
+ self.client_manager = ClientManager(
+ global_agent_store_id=None # 使用默认的"global_agent_store"
+ )
+ # 注意:client_services_path和agent_clients_path参数已废弃,保留在__init__参数中只为向后兼容
+
+ # 会话管理器
+ self.session_manager = SessionManager()
+
+ # 本地服务管理器
+ self.local_service_manager = get_local_service_manager()
+
+
+ # 🆕 事件驱动架构:生命周期管理器将由 ServiceContainer 管理
+ # 保留属性以兼容旧代码,但实际使用 store.container.lifecycle_manager
+ self.lifecycle_manager = None # 将在 store 初始化后设置
+
+ # 🆕 事件驱动架构:内容管理器暂时保留(未来可能迁移到事件驱动)
+ # self.content_manager = ServiceContentManager(self)
+ self.content_manager = None # 暂时禁用,避免依赖旧的 lifecycle_manager
+
+ # 旧的工具更新监控器(保留兼容性,但将被废弃)
+ self.tools_update_monitor = None
+
+ def _get_timestamp(self) -> str:
+ """获取统一格式的时间戳"""
+ return time.strftime("%Y-%m-%d %H:%M:%S")
+
+ def _safe_model_dump(self, obj) -> Dict[str, Any]:
+ """安全地调用model_dump方法"""
+ try:
+ if hasattr(obj, 'model_dump'):
+ return obj.model_dump()
+ elif hasattr(obj, 'dict'):
+ return obj.dict()
+ else:
+ # 如果没有序列化方法,尝试转换为字典
+ return dict(obj) if hasattr(obj, '__dict__') else str(obj)
+ except Exception as e:
+ logger.warning(f"Failed to serialize object {type(obj)}: {e}")
+ return {"error": f"Serialization failed: {str(e)}", "type": str(type(obj))}
+
+ def _validate_configuration(self) -> bool:
+ """验证配置的有效性
+
+ Returns:
+ bool: 配置是否有效
+ """
+ try:
+ # 检查基本配置
+ if not isinstance(self.config, dict):
+ logger.error("Configuration must be a dictionary")
+ return False
+
+ # 检查timing配置
+ timing_config = self.config.get("timing", {})
+ if not isinstance(timing_config, dict):
+ logger.error("Timing configuration must be a dictionary")
+ return False
+
+ # 检查http_timeout
+ http_timeout = timing_config.get("http_timeout_seconds", 10)
+ if not isinstance(http_timeout, (int, float)) or http_timeout <= 0:
+ logger.error("http_timeout_seconds must be a positive number")
+ return False
+
+ logger.info("Configuration validation passed")
+ return True
+ except Exception as e:
+ logger.error(f"Configuration validation failed: {e}")
+ return False
+
+ async def setup(self):
+ """初始化编排器资源"""
+ logger.info("Setting up MCP Orchestrator...")
+
+ # 健康管理器配置已移除(事件驱动架构直接使用容器的 HealthMonitor)
+
+ # 初始化工具更新监控器
+ self._setup_tools_update_monitor()
+
+ # 🆕 事件驱动架构:启动 ServiceContainer(如果已设置)
+ if self.container:
+ logger.info("Starting ServiceContainer components...")
+ await self.container.start()
+ logger.info("ServiceContainer components started")
+ else:
+ logger.warning("ServiceContainer not available, skipping container startup")
+
+ # 启动监控任务(仅启动保留的工具更新监控器)
+ try:
+ await self._start_monitoring()
+ except Exception as e:
+ logger.warning(f"Failed to start monitoring tasks: {e}")
+
+ # 新增:启动统一同步管理器
+ try:
+ logger.info("About to call _setup_sync_manager()...")
+ await self._setup_sync_manager()
+ logger.info("_setup_sync_manager() completed successfully")
+ except Exception as e:
+ logger.error(f"Exception in _setup_sync_manager(): {e}")
+ import traceback
+ logger.error(f"_setup_sync_manager() traceback: {traceback.format_exc()}")
+
+ # 只做必要的资源初始化
+ logger.info("MCP Orchestrator setup completed with event-driven architecture")
+
+ async def _setup_sync_manager(self):
+ """设置统一同步管理器"""
+ try:
+ logger.info(f"Setting up sync manager... standalone_config_manager={self.standalone_config_manager}")
+
+ # 检查是否已经启动
+ if hasattr(self, 'sync_manager') and self.sync_manager and self.sync_manager.is_running:
+ logger.info("Unified sync manager already running, skipping...")
+ return
+
+ # 只有在非独立配置模式下才启用文件监听同步
+ if not self.standalone_config_manager:
+ logger.info("Creating unified sync manager...")
+ from mcpstore.core.sync.unified_sync_manager import UnifiedMCPSyncManager
+ if not hasattr(self, 'sync_manager') or not self.sync_manager:
+ logger.info("Initializing UnifiedMCPSyncManager...")
+ self.sync_manager = UnifiedMCPSyncManager(self)
+ logger.info("UnifiedMCPSyncManager created successfully")
+
+ logger.info("Starting sync manager...")
+ await self.sync_manager.start()
+ logger.info("Unified sync manager started successfully")
+ else:
+ logger.info("Standalone mode: sync manager disabled (no file watching)")
+ except Exception as e:
+ logger.error(f"Failed to setup sync manager: {e}")
+ import traceback
+ logger.error(f"Sync manager setup traceback: {traceback.format_exc()}")
+ # 不抛出异常,允许系统继续运行
+
+ async def cleanup(self):
+ """清理orchestrator资源"""
+ try:
+ logger.info("Cleaning up MCP Orchestrator...")
+
+ # 停止同步管理器
+ if self.sync_manager:
+ await self.sync_manager.stop()
+ self.sync_manager = None
+
+ # 🆕 事件驱动架构:停止 ServiceContainer
+ if self.container:
+ logger.info("Stopping ServiceContainer components...")
+ await self.container.stop()
+ logger.info("ServiceContainer components stopped")
+
+ logger.info("MCP Orchestrator cleanup completed")
+
+ except Exception as e:
+ logger.error(f"Error during orchestrator cleanup: {e}")
+
+ async def shutdown(self):
+ """关闭编排器并清理资源"""
+ logger.info("Shutting down MCP Orchestrator...")
+
+ # 🆕 事件驱动架构:停止 ServiceContainer
+ try:
+ if self.container:
+ logger.debug("Stopping ServiceContainer...")
+ await self.container.stop()
+ logger.debug("ServiceContainer stopped")
+ except Exception as e:
+ logger.error(f"Error stopping ServiceContainer: {e}")
+
+ logger.info("MCP Orchestrator shutdown completed")
+
+
+ def _setup_tools_update_monitor(self):
+ """设置工具更新监控器"""
+ try:
+ from mcpstore.extensions.monitoring import ToolsUpdateMonitor
+ self.tools_update_monitor = ToolsUpdateMonitor(self)
+ logger.info("Tools update monitor initialized")
+ except Exception as e:
+ logger.error(f"Failed to setup tools update monitor: {e}")
+
+ async def _start_monitoring(self):
+ """启动监控任务"""
+ try:
+ # 工具更新监控现在由ContentManager在ServiceContainer中处理
+ # 这里只做一些基础的监控设置
+ logger.info("Starting monitoring tasks...")
+
+ # 如果有工具更新监控器,可以在这里启动
+ if hasattr(self, 'tools_update_monitor') and self.tools_update_monitor:
+ try:
+ # 启动工具更新监控(如果可用)
+ if hasattr(self.tools_update_monitor, 'start'):
+ await self.tools_update_monitor.start()
+ logger.info("Tools update monitor started")
+ except Exception as e:
+ logger.debug(f"Tools update monitor start failed: {e}")
+
+ # 监控任务现在主要由ServiceContainer中的各个管理器处理
+ # HealthMonitor, LifecycleManager等在ServiceContainer.start()中已启动
+ logger.info("Monitoring tasks setup completed")
+
+ except Exception as e:
+ logger.error(f"Failed to setup monitoring tasks: {e}")
+ raise
+
+ # _create_standalone_mcp_config 方法现在在 StandaloneConfigMixin 中实现
diff --git a/src/mcpstore/core/orchestrator/network_utils.py b/src/mcpstore/core/orchestrator/network_utils.py
new file mode 100644
index 00000000..0a4fdc8c
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/network_utils.py
@@ -0,0 +1,32 @@
+"""
+MCPOrchestrator Network Utils Module
+Network utilities module - contains network error detection and utility methods
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+class NetworkUtilsMixin:
+ """Network utilities mixin class"""
+
+ def _is_network_error(self, error: Exception) -> bool:
+ """Determine if it's a network-related error"""
+ error_str = str(error).lower()
+ network_error_keywords = [
+ 'connection', 'network', 'timeout', 'unreachable',
+ 'refused', 'reset', 'dns', 'resolve', 'socket'
+ ]
+ return any(keyword in error_str for keyword in network_error_keywords)
+
+ def _is_filesystem_error(self, error: Exception) -> bool:
+ """Determine if it's a filesystem-related error"""
+ if isinstance(error, (FileNotFoundError, PermissionError, OSError, IOError)):
+ return True
+
+ error_str = str(error).lower()
+ filesystem_error_keywords = [
+ 'no such file', 'file not found', 'permission denied',
+ 'access denied', 'directory not found', 'path not found'
+ ]
+ return any(keyword in error_str for keyword in filesystem_error_keywords)
diff --git a/src/mcpstore/core/orchestrator/resources_prompts.py b/src/mcpstore/core/orchestrator/resources_prompts.py
new file mode 100644
index 00000000..547b0b06
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/resources_prompts.py
@@ -0,0 +1,616 @@
+"""
+MCPOrchestrator Resources and Prompts Module
+Resources/Prompts模块 - 包含FastMCP的Resources和Prompts功能支持
+"""
+
+import asyncio
+import logging
+import time
+from typing import Dict, Any, Optional
+
+from mcpstore.core.utils.mcp_client_helpers import temp_client_for_service
+
+logger = logging.getLogger(__name__)
+
+class ResourcesPromptsMixin:
+ """Resources/Prompts混入类"""
+
+ # === 工具变更检测接口 ===
+
+ def list_changed_tools(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None,
+ force_refresh: bool = False
+ ) -> Dict[str, Any]:
+ """
+ 工具变更检测和处理方法(同步版本)
+
+ Args:
+ service_name: 特定服务名(可选,None表示检查所有服务)
+ client_id: 特定客户端ID(可选)
+ force_refresh: 是否强制刷新(忽略缓存和时间间隔)
+
+ Returns:
+ Dict: 包含变更信息的响应
+ {
+ "changed": bool, # 是否有变更
+ "services": List[str], # 发生变更的服务列表
+ "trigger": str, # 触发方式:"notification" | "polling" | "manual"
+ "timestamp": str, # 检测时间
+ "details": Dict # 详细变更信息
+ }
+ """
+ if self.tools_update_monitor:
+ return self.tools_update_monitor.list_changed_tools(
+ service_name=service_name,
+ client_id=client_id,
+ force_refresh=force_refresh,
+ trigger="manual"
+ )
+ else:
+ logger.warning("ToolsUpdateMonitor not available")
+ return {
+ "changed": False,
+ "services": [],
+ "trigger": "manual",
+ "timestamp": self._get_timestamp(),
+ "details": {"error": "ToolsUpdateMonitor not available"}
+ }
+
+ async def list_changed_tools_async(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None,
+ force_refresh: bool = False,
+ trigger: str = "manual"
+ ) -> Dict[str, Any]:
+ """
+ 工具变更检测和处理方法(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+ force_refresh: 是否强制刷新
+ trigger: 触发方式
+
+ Returns:
+ Dict: 包含变更信息的响应
+ """
+ if self.tools_update_monitor:
+ return await self.tools_update_monitor.list_changed_tools_async(
+ service_name=service_name,
+ client_id=client_id,
+ force_refresh=force_refresh,
+ trigger=trigger
+ )
+ else:
+ logger.warning("ToolsUpdateMonitor not available")
+ return {
+ "changed": False,
+ "services": [],
+ "trigger": trigger,
+ "timestamp": self._get_timestamp(),
+ "details": {"error": "ToolsUpdateMonitor not available"}
+ }
+
+ # === Resources操作支持 ===
+
+ def list_resources(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的资源(同步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源列表的响应
+ """
+ return asyncio.run(self.list_resources_async(service_name, client_id))
+
+ async def list_resources_async(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的资源(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源列表的响应
+ """
+ try:
+ if not client_id:
+ client_id = self.client_manager.global_agent_store_id
+
+ if service_name:
+ # 获取特定服务的资源
+ # 从Registry获取当前活跃会话
+ service_config = await self.registry.get_service_config_from_cache_async(client_id, service_name)
+ if not service_config:
+ return {
+ "success": False,
+ "error": f"Service '{service_name}' not found or not configured",
+ "data": [],
+ "service_name": service_name,
+ "timestamp": self._get_timestamp()
+ }
+
+ async with temp_client_for_service(service_name, service_config) as client:
+ resources = await client.list_resources()
+ return {
+ "success": True,
+ "data": [self._safe_model_dump(resource) for resource in resources],
+ "service_name": service_name,
+ "timestamp": self._get_timestamp(),
+ "count": len(resources)
+ }
+ else:
+ # 获取所有服务的资源
+ all_resources = {}
+ services = self.registry.get_services(client_id)
+
+ for sname in services:
+ try:
+ s_config = await self.registry.get_service_config_from_cache_async(client_id, sname)
+ if not s_config:
+ all_resources[sname] = []
+ continue
+ async with temp_client_for_service(sname, s_config) as client:
+ resources = await client.list_resources()
+ all_resources[sname] = [self._safe_model_dump(resource) for resource in resources]
+ except Exception as e:
+ logger.warning(f"Failed to get resources from service {sname}: {e}")
+ all_resources[sname] = []
+
+ total_count = sum(len(resources) for resources in all_resources.values())
+ return {
+ "success": True,
+ "data": all_resources,
+ "timestamp": self._get_timestamp(),
+ "total_count": total_count,
+ "services_count": len(all_resources)
+ }
+
+ except Exception as e:
+ logger.error(f"Error listing resources: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "data": [],
+ "timestamp": self._get_timestamp()
+ }
+
+ def list_resource_templates(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的资源模板(同步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源模板列表的响应
+ """
+ return asyncio.run(self.list_resource_templates_async(service_name, client_id))
+
+ async def list_resource_templates_async(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的资源模板(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源模板列表的响应
+ """
+ try:
+ if not client_id:
+ client_id = self.client_manager.global_agent_store_id
+
+ if service_name:
+ # 获取特定服务的资源模板(使用临时client)
+ service_config = await self.registry.get_service_config_from_cache_async(client_id, service_name)
+ if not service_config:
+ return {
+ "success": False,
+ "error": f"Service '{service_name}' not found or not configured",
+ "data": [],
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ async with temp_client_for_service(service_name, service_config) as client:
+ templates = await client.list_resource_templates()
+ return {
+ "success": True,
+ "data": [self._safe_model_dump(template) for template in templates],
+ "service_name": service_name,
+ "timestamp": self._get_timestamp(),
+ "count": len(templates)
+ }
+ else:
+ # 获取所有服务的资源模板
+ all_templates = {}
+ services = self.registry.get_services(client_id)
+
+ for sname in services:
+ try:
+ s_config = await self.registry.get_service_config_from_cache_async(client_id, sname)
+ if not s_config:
+ all_templates[sname] = []
+ continue
+ async with temp_client_for_service(sname, s_config) as client:
+ templates = await client.list_resource_templates()
+ all_templates[sname] = [template.model_dump() for template in templates]
+ except Exception as e:
+ logger.warning(f"Failed to get resource templates from service {sname}: {e}")
+ all_templates[sname] = []
+
+ total_count = sum(len(templates) for templates in all_templates.values())
+ return {
+ "success": True,
+ "data": all_templates,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "total_count": total_count,
+ "services_count": len(all_templates)
+ }
+
+ except Exception as e:
+ logger.error(f"Error listing resource templates: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "data": [],
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ def read_resource(
+ self,
+ uri: str,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 读取资源内容(同步版本)
+
+ Args:
+ uri: 资源URI
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源内容的响应
+ """
+ return asyncio.run(self.read_resource_async(uri, service_name, client_id))
+
+ async def read_resource_async(
+ self,
+ uri: str,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 读取资源内容(异步版本)
+
+ Args:
+ uri: 资源URI
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含资源内容的响应
+ """
+ # 参数验证
+ if not uri or not isinstance(uri, str):
+ return {
+ "success": False,
+ "error": "Invalid URI parameter: URI must be a non-empty string",
+ "data": None,
+ "uri": uri,
+ "timestamp": self._get_timestamp()
+ }
+
+ try:
+ if not client_id:
+ client_id = self.client_manager.global_agent_store_id
+
+ if service_name:
+ # 从特定服务读取资源(使用临时client)
+ service_config = await self.registry.get_service_config_from_cache_async(client_id, service_name)
+ if not service_config:
+ return {
+ "success": False,
+ "error": f"Service '{service_name}' not found or not configured",
+ "data": None,
+ "uri": uri,
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ async with temp_client_for_service(service_name, service_config) as client:
+ content = await client.read_resource(uri)
+ return {
+ "success": True,
+ "data": [self._safe_model_dump(item) for item in content],
+ "uri": uri,
+ "service_name": service_name,
+ "timestamp": self._get_timestamp(),
+ "content_count": len(content)
+ }
+ else:
+ # TODO: 权限控制 - 后续考虑添加资源访问权限验证
+ # TODO: 缓存策略 - 后续考虑添加资源内容缓存
+
+ # 尝试从所有服务读取资源(找到第一个匹配的)
+ services = self.registry.get_services(client_id)
+ last_error = None
+
+ for sname in services:
+ try:
+ s_config = await self.registry.get_service_config_from_cache_async(client_id, sname)
+ if not s_config:
+ continue
+ async with temp_client_for_service(sname, s_config) as client:
+ content = await client.read_resource(uri)
+ return {
+ "success": True,
+ "data": [item.model_dump() for item in content],
+ "uri": uri,
+ "service_name": sname,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "content_count": len(content)
+ }
+ except Exception as e:
+ last_error = e
+ continue
+
+ return {
+ "success": False,
+ "error": f"Resource '{uri}' not found in any service. Last error: {last_error}",
+ "data": None,
+ "uri": uri,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ except Exception as e:
+ logger.error(f"Error reading resource {uri}: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "data": None,
+ "uri": uri,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ # === Prompts操作支持 ===
+
+ def list_prompts(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的提示词(同步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含提示词列表的响应
+ """
+ return asyncio.run(self.list_prompts_async(service_name, client_id))
+
+ async def list_prompts_async(
+ self,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 列出可用的提示词(异步版本)
+
+ Args:
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含提示词列表的响应
+ """
+ try:
+ if not client_id:
+ client_id = self.client_manager.global_agent_store_id
+
+ if service_name:
+ # 获取特定服务的提示词
+ service_config = await self.registry.get_service_config_from_cache_async(client_id, service_name)
+ if not service_config:
+ return {
+ "success": False,
+ "error": f"Service '{service_name}' not found or not configured",
+ "data": [],
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ async with temp_client_for_service(service_name, service_config) as client:
+ prompts = await client.list_prompts()
+ return {
+ "success": True,
+ "data": [prompt.model_dump() for prompt in prompts],
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "count": len(prompts)
+ }
+ else:
+ # 获取所有服务的提示词
+ all_prompts = {}
+ services = self.registry.get_services(client_id)
+
+ for sname in services:
+ try:
+ s_config = await self.registry.get_service_config_from_cache_async(client_id, sname)
+ if not s_config:
+ all_prompts[sname] = []
+ continue
+ async with temp_client_for_service(sname, s_config) as client:
+ prompts = await client.list_prompts()
+ all_prompts[sname] = [prompt.model_dump() for prompt in prompts]
+ except Exception as e:
+ logger.warning(f"Failed to get prompts from service {sname}: {e}")
+ all_prompts[sname] = []
+
+ total_count = sum(len(prompts) for prompts in all_prompts.values())
+ return {
+ "success": True,
+ "data": all_prompts,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "total_count": total_count,
+ "services_count": len(all_prompts)
+ }
+
+ except Exception as e:
+ logger.error(f"Error listing prompts: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "data": [],
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ def get_prompt(
+ self,
+ name: str,
+ arguments: Optional[Dict] = None,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 获取提示词内容(同步版本)
+
+ Args:
+ name: 提示词名称
+ arguments: 提示词参数(可选)
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含提示词内容的响应
+ """
+ return asyncio.run(self.get_prompt_async(name, arguments, service_name, client_id))
+
+ async def get_prompt_async(
+ self,
+ name: str,
+ arguments: Optional[Dict] = None,
+ service_name: Optional[str] = None,
+ client_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 获取提示词内容(异步版本)
+
+ Args:
+ name: 提示词名称
+ arguments: 提示词参数(可选)
+ service_name: 特定服务名(可选)
+ client_id: 特定客户端ID(可选)
+
+ Returns:
+ Dict: 包含提示词内容的响应
+ """
+ try:
+ if not client_id:
+ client_id = self.client_manager.global_agent_store_id
+
+ if arguments is None:
+ arguments = {}
+
+ if service_name:
+ # 从特定服务获取提示词(使用临时client)
+ service_config = await self.registry.get_service_config_from_cache_async(client_id, service_name)
+ if not service_config:
+ return {
+ "success": False,
+ "error": f"Service '{service_name}' not found or not configured",
+ "data": None,
+ "name": name,
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ async with temp_client_for_service(service_name, service_config) as client:
+ result = await client.get_prompt(name, arguments)
+ return {
+ "success": True,
+ "data": result.model_dump(),
+ "name": name,
+ "arguments": arguments,
+ "service_name": service_name,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "message_count": len(result.messages)
+ }
+ else:
+ # TODO: 权限控制 - 后续考虑添加提示词访问权限验证
+ # TODO: 缓存策略 - 后续考虑添加提示词内容缓存
+
+ # 尝试从所有服务获取提示词(找到第一个匹配的)
+ services = self.registry.get_services(client_id)
+ last_error = None
+
+ for sname in services:
+ try:
+ s_config = await self.registry.get_service_config_from_cache_async(client_id, sname)
+ if not s_config:
+ continue
+ async with temp_client_for_service(sname, s_config) as client:
+ result = await client.get_prompt(name, arguments)
+ return {
+ "success": True,
+ "data": result.model_dump(),
+ "name": name,
+ "arguments": arguments,
+ "service_name": sname,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "message_count": len(result.messages)
+ }
+ except Exception as e:
+ last_error = e
+ continue
+
+ return {
+ "success": False,
+ "error": f"Prompt '{name}' not found in any service. Last error: {last_error}",
+ "data": None,
+ "name": name,
+ "arguments": arguments,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting prompt {name}: {e}")
+ return {
+ "success": False,
+ "error": str(e),
+ "data": None,
+ "name": name,
+ "arguments": arguments,
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
+ }
diff --git a/src/mcpstore/core/orchestrator/service_connection.py b/src/mcpstore/core/orchestrator/service_connection.py
new file mode 100644
index 00000000..9463f927
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/service_connection.py
@@ -0,0 +1,233 @@
+"""
+MCPOrchestrator Service Connection Module
+
+服务连接模块 - 通过事件驱动的标准流程处理服务连接
+
+重要设计原则:
+- 所有服务连接必须通过事件驱动的标准流程
+- 不允许绕过 ServiceAddRequested -> ServiceCached -> ServiceConnected 流程
+- 确保 service_metadata 在连接前被正确创建
+"""
+
+import logging
+from typing import Dict, Any, Optional, Tuple
+
+from fastmcp import Client
+
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+class ServiceConnectionMixin:
+ """
+ 服务连接混入类
+
+ 重要设计原则:
+ - 所有服务连接必须通过事件驱动的标准流程
+ - 不允许绕过 ServiceAddRequested -> ServiceCached -> ServiceConnected 流程
+ - 确保 service_metadata 在连接前被正确创建
+ """
+
+ async def connect_service(self, name: str, service_config: Dict[str, Any] = None, url: str = None, agent_id: str = None) -> Tuple[bool, str]:
+ """
+ 连接服务 - 通过事件驱动的标准流程
+
+ 重要:此方法不再直接连接服务,而是发布事件触发标准流程:
+ - 如果服务不存在:发布 ServiceAddRequested 事件(触发完整流程)
+ - 如果服务已存在:发布 ServiceConnectionRequested 事件(只触发连接)
+
+ 这确保了:
+ 1. service_metadata 在连接前被正确创建
+ 2. 所有缓存操作通过 CacheManager 统一处理
+ 3. 生命周期状态正确管理
+
+ Args:
+ name: 服务名称
+ service_config: 服务配置(必须提供)
+ url: 服务 URL(可选,会合并到 service_config)
+ agent_id: Agent ID(可选,默认使用 global_agent_store_id)
+
+ Returns:
+ Tuple[bool, str]: (是否成功发布事件, 消息)
+ """
+ try:
+ # 确定 Agent ID
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+
+ # 获取服务配置
+ if service_config is None:
+ service_config = await self.registry.get_service_config_from_cache_async(agent_key, name)
+ if not service_config:
+ raise RuntimeError(
+ f"Service configuration does not exist: service_name={name}, agent_id={agent_key}. "
+ f"Please add service configuration via add_service first."
+ )
+
+ # 合并 URL 参数
+ if url:
+ service_config = service_config.copy()
+ service_config["url"] = url
+
+ # 检查服务是否已存在(包括服务实体和元数据)
+ # 必须同时检查服务实体和 service_metadata,确保数据一致性
+ service_exists = await self.registry.has_service_async(agent_key, name)
+ metadata_exists = False
+ if service_exists:
+ # 服务实体存在,检查 metadata 是否也存在
+ metadata = await self.registry.get_service_metadata_async(agent_key, name)
+ metadata_exists = metadata is not None
+ if not metadata_exists:
+ logger.warning(
+ f"[CONNECT_SERVICE] Service {name} entity exists but metadata does not exist, "
+ f"data inconsistency detected, will proceed with full add flow"
+ )
+
+ if not service_exists or not metadata_exists:
+ # 服务不存在,走完整的添加流程
+ logger.info(f"[CONNECT_SERVICE] Service {name} does not exist, publishing ServiceAddRequested event")
+
+ from mcpstore.core.events.service_events import ServiceAddRequested
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_key,
+ service_name=name,
+ service_config=service_config,
+ global_agent_store_id=self.client_manager.global_agent_store_id
+ )
+
+ add_event = ServiceAddRequested(
+ agent_id=agent_key,
+ service_name=name,
+ service_config=service_config,
+ client_id=client_id,
+ source="connect_service"
+ )
+
+ # 同步等待事件处理完成
+ await self.container._event_bus.publish(add_event, wait=True)
+ logger.info(f"[CONNECT_SERVICE] ServiceAddRequested event published: {name}")
+ return True, f"Service {name} add request published, processing"
+ else:
+ # 服务已存在,只触发重新连接
+ logger.info(f"[CONNECT_SERVICE] Service {name} already exists, publishing ServiceConnectionRequested event")
+
+ from mcpstore.core.events.service_events import ServiceConnectionRequested
+
+ connection_event = ServiceConnectionRequested(
+ agent_id=agent_key,
+ service_name=name,
+ service_config=service_config,
+ timeout=3.0
+ )
+
+ # 同步等待事件处理完成
+ await self.container._event_bus.publish(connection_event, wait=True)
+ logger.info(f"[CONNECT_SERVICE] ServiceConnectionRequested event published: {name}")
+ return True, f"Service {name} connection request published, processing"
+
+ except Exception as e:
+ logger.error(f"[CONNECT_SERVICE] Failed to connect service {name}: {e}")
+ raise
+
+ # ========================================
+ # 以下方法已废弃,服务连接现在通过事件驱动流程处理
+ # _connect_local_service, _connect_remote_service, _update_service_cache
+ # 已删除,由 ConnectionManager 和 CacheManager 统一处理
+ # ========================================
+
+ async def disconnect_service(self, url_or_name: str) -> bool:
+ """Remove service from global_agent_store"""
+ logger.info(f"Removing service: {url_or_name}")
+
+ # Find service name to remove
+ name_to_remove = None
+ for name, server in self.global_agent_store_config.get("mcpServers", {}).items():
+ if name == url_or_name or server.get("url") == url_or_name:
+ name_to_remove = name
+ break
+
+ if name_to_remove:
+ # Remove from global_agent_store_config
+ if name_to_remove in self.global_agent_store_config["mcpServers"]:
+ del self.global_agent_store_config["mcpServers"][name_to_remove]
+
+ # Remove from configuration file
+ ok = self.mcp_config.remove_service(name_to_remove)
+ if not ok:
+ logger.warning(f"Failed to remove service {name_to_remove} from configuration file")
+
+ # Remove from registry (using async version)
+ agent_id = self.client_manager.global_agent_store_id
+ await self.registry.remove_service_async(agent_id, name_to_remove)
+
+ # Rebuild global_agent_store
+ if self.global_agent_store_config.get("mcpServers"):
+ self.global_agent_store = Client(self.global_agent_store_config)
+
+ # Rebuild agent_clients
+ for agent_id in list(self.agent_clients.keys()):
+ self.agent_clients[agent_id] = Client(self.global_agent_store_config)
+ logger.info(f"Updated client for agent {agent_id} after removing service")
+
+ else:
+ # Clear global_agent_store if no services remain
+ self.global_agent_store = None
+ # Clear agent_clients
+ self.agent_clients.clear()
+
+ return True
+ else:
+ logger.warning(f"Service {url_or_name} not found in configuration.")
+ return False
+
+ async def refresh_services(self):
+ """Refresh services from mcp.json"""
+ # Sync from mcp.json file
+ if hasattr(self, 'sync_manager') and self.sync_manager:
+ await self.sync_manager.sync_global_agent_store_from_mcp_json()
+ else:
+ logger.warning("Sync manager not available, cannot refresh services")
+
+ async def refresh_service_content(self, service_name: str, agent_id: str = None) -> bool:
+ """Refresh service content (tools, resources, etc.)"""
+ if self.content_manager is None:
+ raise RuntimeError(
+ f"content_manager is not initialized, cannot refresh service content: service_name={service_name}"
+ )
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return await self.content_manager.force_update_service_content(agent_key, service_name)
+
+ async def is_service_healthy(self, name: str, client_id: Optional[str] = None) -> bool:
+ """
+ Check if service is healthy
+
+ Args:
+ name: Service name
+ client_id: Client ID, if None uses global_agent_store_id
+
+ Returns:
+ bool: True if service is HEALTHY/WARNING state
+ """
+ agent_key = client_id or self.client_manager.global_agent_store_id
+ state = await self.registry._service_state_service.get_service_state_async(agent_key, name)
+ return state in (ServiceConnectionState.HEALTHY, ServiceConnectionState.WARNING)
+
+ def _normalize_service_config(self, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """Normalize service configuration"""
+ if not service_config:
+ return service_config
+
+ # Copy configuration
+ normalized = service_config.copy()
+
+ # Auto-infer transport type from URL
+ if "url" in normalized and "transport" not in normalized:
+ url = normalized["url"]
+ if "/sse" in url.lower():
+ normalized["transport"] = "sse"
+ else:
+ normalized["transport"] = "streamable-http"
+ logger.debug(f"Auto-inferred transport type: {normalized['transport']} for URL: {url}")
+
+ return normalized
diff --git a/src/mcpstore/core/orchestrator/service_management.py b/src/mcpstore/core/orchestrator/service_management.py
new file mode 100644
index 00000000..456885c9
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/service_management.py
@@ -0,0 +1,474 @@
+"""
+MCPOrchestrator Service Management Module
+Service management module - contains service registration, management and information retrieval
+"""
+
+import logging
+from typing import Dict, List, Any, Optional
+
+from fastmcp import Client
+
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+class ServiceManagementMixin:
+ """Service management mixin class"""
+
+ # tools_snapshot 和 _get_all_tools_from_cache 已删除
+ # 所有工具数据直接从 pykv 读取,不使用快照
+ # 参见 tool_operations.py 中的 list_tools_async 方法
+
+ async def register_agent_client(self, agent_id: str, config: Dict[str, Any] = None) -> Client:
+ """
+ Register a new client instance for agent
+
+ Args:
+ agent_id: Agent ID
+ config: Optional configuration, if None use main_config
+
+ Returns:
+ Newly created Client instance
+ """
+ # Use main_config or provided config to create new client
+ agent_config = config or self.main_config
+ agent_client = Client(agent_config)
+
+ # Store agent_client
+ self.agent_clients[agent_id] = agent_client
+ logger.debug(f"Registered agent client for {agent_id}")
+
+ return agent_client
+
+ def get_agent_client(self, agent_id: str) -> Optional[Client]:
+ """
+ Get client instance for agent
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ Client instance or None
+ """
+ return self.agent_clients.get(agent_id)
+
+ async def start_global_agent_store(self, config: Dict[str, Any]):
+ """Start global_agent_store async with lifecycle, register services and tools (healthy services only)"""
+ # Get list of healthy services
+ # 直接查询健康服务(基于当前生命周期状态)
+ processable_states = [
+ ServiceConnectionState.HEALTHY,
+ ServiceConnectionState.WARNING,
+ ServiceConnectionState.INITIALIZING,
+ ]
+ healthy_services: List[str] = []
+ agent_id = self.client_manager.global_agent_store_id
+
+ for name in config.get("mcpServers", {}).keys():
+ state = await self.registry._service_state_service.get_service_state_async(agent_id, name)
+
+ # 新服务(state=None)也应纳入处理范围
+ if state is None or state in processable_states:
+ healthy_services.append(name)
+
+ # Create new configuration containing only healthy services
+ healthy_config = {
+ "mcpServers": {
+ name: config["mcpServers"][name]
+ for name in healthy_services
+ }
+ }
+
+ # Use unified registration path (replacing deprecated register_json_services)
+ try:
+ if self._context_factory:
+ context = self._context_factory()
+ await context.add_service_async(healthy_config)
+ else:
+ logger.warning("Orchestrator context factory not available; skipping auto registration pipeline")
+ except Exception as e:
+ logger.error(f"Failed to register healthy services via add_service_async: {e}")
+
+ # register_json_services removed (Deprecated)
+
+ def _infer_service_from_tool(self, tool_name: str, service_names: List[str]) -> str:
+ """Infer service name from tool name"""
+ # Simple inference logic: find service name contained in tool name
+ for service_name in service_names:
+ if service_name.lower() in tool_name.lower():
+ return service_name
+
+ # If no match, return first service name (assuming single service configuration)
+ return service_names[0] if service_names else "unknown_service"
+
+ def create_client_config_from_names(self, service_names: list) -> Dict[str, Any]:
+ """
+ Generate new client config from mcp.json based on service name list
+ """
+ all_services = self.mcp_config.load_config().get("mcpServers", {})
+ selected = {name: all_services[name] for name in service_names if name in all_services}
+ return {"mcpServers": selected}
+
+ async def remove_service(self, service_name: str, agent_id: str = None):
+ """
+ Remove service and handle lifecycle state
+
+ Args:
+ service_name: 服务名称
+ agent_id: Agent ID(可选)
+ """
+ try:
+ # Fix: safer agent_id handling
+ if agent_id is None:
+ if not hasattr(self.client_manager, 'global_agent_store_id'):
+ logger.error("No agent_id provided and global_agent_store_id not available")
+ raise ValueError("Agent ID is required for service removal")
+ agent_key = self.client_manager.global_agent_store_id
+ logger.debug(f"Using global_agent_store_id: {agent_key}")
+ else:
+ agent_key = agent_id
+ logger.debug(f"Using provided agent_id: {agent_key}")
+
+ # 🆕 Event-driven architecture: directly check service status from registry
+ current_state = await self.registry._service_state_service.get_service_state_async(agent_key, service_name)
+ if current_state is None:
+ logger.warning(f"Service {service_name} not found in lifecycle manager for agent {agent_key}")
+ # Check if it exists in the registry(使用异步 API)
+ if not await self.registry.has_service_async(agent_key, service_name):
+ logger.warning(f"Service {service_name} not found in registry for agent {agent_key}, skipping removal")
+ return
+ else:
+ logger.debug(f"Service {service_name} found in registry but not in lifecycle, cleaning up")
+
+ if current_state:
+ logger.debug(f"Removing service {service_name} from agent {agent_key} (state: {current_state.value})")
+ else:
+ logger.debug(f"Removing service {service_name} from agent {agent_key} (no lifecycle state)")
+
+ # Fix: safely call removal methods for each component
+ try:
+ # Notify lifecycle manager to start graceful disconnect (if service exists in lifecycle manager)
+ if current_state:
+ await self.lifecycle_manager.graceful_disconnect(agent_key, service_name, "user_requested")
+ except Exception as e:
+ logger.warning(f"Error during graceful disconnect: {e}")
+
+ try:
+ # Remove from content monitoring
+ self.content_manager.remove_service_from_monitoring(agent_key, service_name)
+ except Exception as e:
+ logger.warning(f"Error removing from content monitoring: {e}")
+
+ try:
+ # Remove service from registry(使用异步版本)
+ await self.registry.remove_service_async(agent_key, service_name)
+
+ # Cancel health monitoring (if exists)
+ try:
+ if self.container:
+ hm = getattr(self.container, 'health_monitor', None)
+ if hm and hasattr(hm, '_health_check_tasks'):
+ task_key = (agent_key, service_name)
+ task = hm._health_check_tasks.pop(task_key, None)
+ if task and not task.done():
+ task.cancel()
+ logger.debug(f"[HEALTH] Unwatched removed service: {service_name} (agent={agent_key})")
+ except Exception as e:
+ logger.debug(f"[HEALTH] Unwatch removed service failed: {e}")
+ except Exception as e:
+ logger.warning(f"Error removing from registry: {e}")
+
+ try:
+ # Remove lifecycle data
+ self.lifecycle_manager.remove_service(agent_key, service_name)
+ except Exception as e:
+ logger.warning(f"Error removing lifecycle data: {e}")
+
+ # 清理服务状态(使用 StateManager)
+ try:
+ # 获取服务的全局名称
+ global_agent_store_id = self.client_manager.global_agent_store_id
+ if agent_key != global_agent_store_id:
+ # Agent 模式:需要获取全局服务名
+ service_global_name = self.registry.get_global_name_from_agent_service(
+ agent_key, service_name
+ )
+ else:
+ # Store 模式:服务名就是全局名称
+ service_global_name = service_name
+
+ if service_global_name:
+ # 使用新的状态管理器删除服务状态
+ state_manager = self.registry._cache_state_manager
+ await state_manager.delete_service_status(service_global_name)
+ logger.info(
+ f"Service status cleanup successful: service_global_name={service_global_name}"
+ )
+ else:
+ logger.debug(
+ f"Cannot get service global name, skipping status cleanup: "
+ f"agent_id={agent_key}, service_name={service_name}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Service status cleanup failed (does not affect service removal): "
+ f"agent_id={agent_key}, service_name={service_name}, error={e}"
+ )
+
+ logger.debug(f"Service removal completed: {service_name} from agent {agent_key}")
+
+ except Exception as e:
+ logger.error(f"Error removing service {service_name}: {e}")
+ import traceback
+ logger.error(f"Traceback: {traceback.format_exc()}")
+ raise
+
+ def get_session(self, service_name: str, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.get_session(agent_key, service_name)
+
+ def get_tools_for_service(self, service_name: str, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.get_tools_for_service(agent_key, service_name)
+
+ def get_all_service_names(self, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.get_all_service_names(agent_key)
+
+ def get_all_tool_info(self, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.get_all_tool_info(agent_key)
+
+ def get_service_details(self, service_name: str, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.get_service_details(agent_key, service_name)
+
+ # 🆕 Event-driven architecture: the following methods have been deprecated and removed
+ # - update_service_health: replaced by ServiceLifecycleManager
+ # - get_last_heartbeat: replaced by ServiceLifecycleManager
+
+ def has_service(self, service_name: str, agent_id: str = None):
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+ return self.registry.has_service(agent_key, service_name)
+
+ async def restart_service(self, service_name: str, agent_id: str = None) -> bool:
+ """
+ Restart service - reset to initializing state, let lifecycle manager reprocess
+
+ Args:
+ service_name: Service name
+ agent_id: Agent ID, if None then use global_agent_store_id
+
+ Returns:
+ bool: Whether restart was successful
+ """
+ try:
+ agent_key = agent_id or self.client_manager.global_agent_store_id
+
+ logger.debug(f"Restarting service {service_name} for agent {agent_key}")
+
+ # Check if service exists(使用异步 API)
+ if not await self.registry.has_service_async(agent_key, service_name):
+ logger.warning(f"[RESTART_SERVICE] Service '{service_name}' not found in registry")
+ return False
+
+ # 从 pykv 异步获取服务元数据
+ metadata = await self.registry.get_service_metadata_async(agent_key, service_name)
+ if not metadata:
+ logger.error(f" [RESTART_SERVICE] No metadata found for service '{service_name}'")
+ raise RuntimeError(f"No metadata found for service '{service_name}'")
+
+ # Reset service state to INITIALIZING(通过 LifecycleManager 统一入口)
+ await self.lifecycle_manager._transition_state(
+ agent_id=agent_key,
+ service_name=service_name,
+ new_state=ServiceConnectionState.INITIALIZING,
+ reason="restart_service",
+ source="ServiceManagement",
+ )
+ logger.debug(f" [RESTART_SERVICE] Set state to INITIALIZING for '{service_name}'")
+
+ # Reset metadata
+ from datetime import datetime
+ metadata.consecutive_failures = 0
+ metadata.consecutive_successes = 0
+ metadata.reconnect_attempts = 0
+ metadata.error_message = None
+ metadata.state_entered_time = datetime.now()
+ metadata.next_retry_time = None
+
+ # Update metadata to registry
+ self.registry.set_service_metadata(agent_key, service_name, metadata)
+ logger.debug(f" [RESTART_SERVICE] Reset metadata for '{service_name}'")
+
+ # Event-driven architecture: directly publish ServiceInitialized, let ConnectionManager handle connection
+ try:
+ from mcpstore.core.events.service_events import ServiceInitialized
+ # Prefer container.event_bus; otherwise fallback to orchestrator.event_bus
+ bus = None
+ bus_source = None
+ if self.container:
+ bus = getattr(self.container, 'event_bus', None)
+ bus_source = 'container.event_bus' if bus else None
+ if not bus:
+ bus = getattr(self, 'event_bus', None)
+ bus_source = bus_source or ('orchestrator.event_bus' if bus else None)
+
+ # Diagnostics: compare bus identities
+ try:
+ container_bus = getattr(self.container, 'event_bus', None) if self.container else None
+ orchestrator_bus = getattr(self, 'event_bus', None)
+ logger.debug(
+ f" [RESTART_SERVICE] bus_diag chosen={hex(id(bus)) if bus else 'None'} "
+ f"container={hex(id(container_bus)) if container_bus else 'None'} "
+ f"orchestrator={hex(id(orchestrator_bus)) if orchestrator_bus else 'None'}"
+ )
+ except Exception as e:
+ logger.debug(f" [RESTART_SERVICE] bus_diag error: {e}")
+
+ if bus:
+ initialized_event = ServiceInitialized(
+ agent_id=agent_key,
+ service_name=service_name,
+ initial_state="initializing"
+ )
+ await bus.publish(initialized_event, wait=True)
+ logger.debug(f" [RESTART_SERVICE] Published ServiceInitialized for '{service_name}' via {bus_source}")
+
+ # Add one-time health check request to ensure quick convergence after initialization (no need to wait for periodic heartbeat)
+ from mcpstore.core.events.service_events import HealthCheckRequested
+ health_check_event = HealthCheckRequested(
+ agent_id=agent_key,
+ service_name=service_name
+ )
+ await bus.publish(health_check_event, wait=True)
+ logger.debug(f" [RESTART_SERVICE] Published HealthCheckRequested for '{service_name}' via {bus_source}")
+ else:
+ logger.warning(" [RESTART_SERVICE] EventBus not available (neither orchestrator nor store.container); cannot publish ServiceInitialized")
+ except Exception as pub_err:
+ logger.warning(f" [RESTART_SERVICE] Failed to publish ServiceInitialized for '{service_name}': {pub_err}")
+
+ logger.info(f"Service restarted successfully: {service_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f" [RESTART_SERVICE] Failed to restart service '{service_name}': {e}")
+ return False
+
+ def _generate_display_name(self, original_tool_name: str, service_name: str) -> str:
+ """
+ Generate user-friendly tool display name
+
+ Args:
+ original_tool_name: Original tool name
+ service_name: Service name
+
+ Returns:
+ User-friendly display name
+ """
+ try:
+ from mcpstore.core.registry.tool_resolver import ToolNameResolver
+ resolver = ToolNameResolver()
+ return resolver.create_user_friendly_name(service_name, original_tool_name)
+ except Exception as e:
+ logger.warning(f"Failed to generate display name for {original_tool_name}: {e}")
+ # Fallback to simple format
+ return f"{service_name}_{original_tool_name}"
+
+ def _is_long_lived_service(self, service_config: Dict[str, Any]) -> bool:
+ """
+ Determine if it's a long connection service
+
+ Args:
+ service_config: Service configuration
+
+ Returns:
+ Whether it's a long connection service
+ """
+ # STDIO services are long connections by default (keep_alive=True)
+ if "command" in service_config:
+ return service_config.get("keep_alive", True)
+
+ # HTTP services are usually also long connections
+ if "url" in service_config:
+ return True
+
+ return False
+
+ async def get_service_status_async(self, service_name: str, client_id: str = None) -> dict:
+ """
+ 异步获取服务状态信息 - 从 pykv 读取
+
+ Args:
+ service_name: 服务名称
+ client_id: Client ID(可选,默认使用 global_agent_store_id)
+
+ Returns:
+ dict: 包含状态信息的字典
+ {
+ "service_name": str,
+ "status": str, # "healthy", "warning", "disconnected", "unknown", etc.
+ "healthy": bool,
+ "last_check": float, # timestamp
+ "response_time": float,
+ "error": str (optional),
+ "client_id": str
+ }
+ """
+ try:
+ agent_key = client_id or self.client_manager.global_agent_store_id
+
+ # 从 pykv 异步获取服务状态
+ state = await self.registry._service_state_service.get_service_state_async(agent_key, service_name)
+ metadata = await self.registry._service_state_service.get_service_metadata_async(
+ agent_key,
+ service_name,
+ )
+
+ # Build status response
+ status_response = {
+ "service_name": service_name,
+ "client_id": agent_key
+ }
+
+ if state:
+ status_response["status"] = state.value
+ # Determine if healthy: both HEALTHY and WARNING are considered healthy
+ from mcpstore.core.models.service import ServiceConnectionState
+ status_response["healthy"] = state in [
+ ServiceConnectionState.HEALTHY,
+ ServiceConnectionState.WARNING
+ ]
+ else:
+ status_response["status"] = "unknown"
+ status_response["healthy"] = False
+
+ if metadata:
+ status_response["last_check"] = metadata.last_health_check.timestamp() if metadata.last_health_check else None
+ status_response["response_time"] = metadata.last_response_time
+ status_response["error"] = metadata.error_message
+ status_response["consecutive_failures"] = metadata.consecutive_failures
+ status_response["state_entered_time"] = metadata.state_entered_time.timestamp() if metadata.state_entered_time else None
+ else:
+ status_response["last_check"] = None
+ status_response["response_time"] = None
+ status_response["error"] = None
+ status_response["consecutive_failures"] = 0
+ status_response["state_entered_time"] = None
+
+ logger.info(f"[GET_STATUS] service='{service_name}' agent_key='{agent_key}' status='{status_response.get('status')}' healthy={status_response.get('healthy')} last_check={status_response.get('last_check')} resp_time={status_response.get('response_time')} cf={status_response.get('consecutive_failures')}")
+ return status_response
+
+ except Exception as e:
+ logger.error(f"Failed to get service status from cache for {service_name}: {e}")
+ return {
+ "service_name": service_name,
+ "status": "error",
+ "healthy": False,
+ "last_check": None,
+ "response_time": None,
+ "error": f"Cache query failed: {str(e)}",
+ "client_id": client_id or (self.client_manager.global_agent_store_id if hasattr(self, 'client_manager') else "unknown"),
+ "consecutive_failures": 0,
+ "state_entered_time": None
+ }
diff --git a/src/mcpstore/core/orchestrator/standalone_config.py b/src/mcpstore/core/orchestrator/standalone_config.py
new file mode 100644
index 00000000..89a04a54
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/standalone_config.py
@@ -0,0 +1,58 @@
+"""
+MCPOrchestrator Standalone Config Module
+Standalone configuration module - contains standalone configuration adapter
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+class StandaloneConfigMixin:
+ """Standalone configuration mixin class"""
+
+ def _create_standalone_mcp_config(self, config_manager):
+ """
+ Create standalone MCP configuration object
+
+ Args:
+ config_manager: Standalone configuration manager
+
+ Returns:
+ Compatible MCP configuration object
+ """
+ class StandaloneMCPConfigAdapter:
+ """Standalone configuration adapter - compatible with MCPConfig interface"""
+
+ def __init__(self, config_manager):
+ self.config_manager = config_manager
+ self.json_path = ":memory:" # Indicates memory configuration
+
+ def load_config(self):
+ """Load configuration"""
+ return self.config_manager.get_mcp_config()
+
+ def get_service_config(self, name):
+ """Get service configuration"""
+ return self.config_manager.get_service_config(name)
+
+ def save_config(self, config):
+ """Save configuration (no actual save in memory mode)"""
+ logger.info("Standalone mode: config save skipped (memory-only)")
+ return True
+
+ def add_service(self, name, config):
+ """Add service"""
+ self.config_manager.add_service_config(name, config)
+ return True
+
+ def remove_service(self, name):
+ """Remove service"""
+ # In standalone mode, we can remove from runtime configuration
+ services = self.config_manager.get_all_service_configs()
+ if name in services:
+ del services[name]
+ logger.info(f"Removed service '{name}' from standalone config")
+ return True
+ return False
+
+ return StandaloneMCPConfigAdapter(config_manager)
diff --git a/src/mcpstore/core/orchestrator/tool_execution.py b/src/mcpstore/core/orchestrator/tool_execution.py
new file mode 100644
index 00000000..978efa46
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/tool_execution.py
@@ -0,0 +1,423 @@
+"""
+MCPOrchestrator Tool Execution Module
+Tool execution module - contains tool execution and processing
+"""
+
+import logging
+from typing import Dict, Any, Optional
+
+from fastmcp import Client
+
+logger = logging.getLogger(__name__)
+
+
+# Correct session implementation based on langchain_mcp_adapters source code analysis
+# Use built-in reentrant context manager features of FastMCP Client
+
+class ToolExecutionMixin:
+ """Tool execution mixin class"""
+
+ async def ensure_persistent_client(self, session, service_name: str):
+ """Public API: ensure a persistent FastMCP client is created and cached.
+
+ This is a non-breaking wrapper exposing the previously private
+ `_create_persistent_client` method, allowing callers (e.g., context/session)
+ to depend on a stable public API.
+ """
+ return await self._create_persistent_client(session, service_name)
+
+ async def execute_tool_fastmcp(
+ self,
+ service_name: str,
+ tool_name: str,
+ arguments: Dict[str, Any] = None,
+ agent_id: Optional[str] = None,
+ timeout: Optional[float] = None,
+ progress_handler = None,
+ raise_on_error: bool = True,
+ session_id: Optional[str] = None
+ ) -> Any:
+ """
+ Execute tool (FastMCP standard)
+ Strictly execute tool calls according to FastMCP official standards
+
+ Args:
+ service_name: Service name
+ tool_name: Tool name (FastMCP original name)
+ arguments: Tool parameters
+ agent_id: Agent ID (optional)
+ timeout: Timeout in seconds
+ progress_handler: Progress handler
+ raise_on_error: Whether to raise exception on error
+ session_id: Session ID (optional, for session-aware execution)
+
+ Returns:
+ FastMCP CallToolResult or extracted data
+ """
+ from mcpstore.core.registry.tool_resolver import FastMCPToolExecutor
+
+ arguments = arguments or {}
+ executor = FastMCPToolExecutor(default_timeout=timeout or 30.0)
+
+ # [SESSION MODE] Use cached FastMCP Client
+ if session_id:
+ logger.info(f"[SESSION_EXECUTION] Using session mode for tool '{tool_name}' in service '{service_name}'")
+ return await self._execute_tool_with_session(
+ session_id, service_name, tool_name, arguments, agent_id,
+ executor, timeout, progress_handler, raise_on_error
+ )
+
+ # [TRADITIONAL MODE] Maintain original logic, ensure backward compatibility
+ logger.debug(f"[TRADITIONAL_EXECUTION] Using traditional mode for tool '{tool_name}' in service '{service_name}'")
+
+ try:
+ # 确定 effective_agent_id
+ effective_agent_id = agent_id if agent_id else self.client_manager.global_agent_store_id
+
+ # [pykv 唯一真相源] 从关系层获取 Agent 的服务列表
+ relation_manager = self.registry._relation_manager
+ agent_services = await relation_manager.get_agent_services(effective_agent_id)
+
+ if not agent_services:
+ raise Exception(f"No services found in pykv for agent {effective_agent_id}")
+
+ logger.debug(f"[TOOL_EXECUTION] pykv relationship layer service count: {len(agent_services)}")
+
+ # 从关系层提取 client_ids
+ client_ids = list(set(
+ svc.get("client_id") for svc in agent_services if svc.get("client_id")
+ ))
+
+ if not client_ids:
+ raise Exception(f"No client_ids found in pykv relations for agent {effective_agent_id}")
+
+ logger.debug(f"[TOOL_EXECUTION] pykv relationship layer client_ids: {client_ids}")
+
+ # 检查服务是否存在于关系层
+ service_exists = any(
+ svc.get("service_global_name") == service_name or
+ svc.get("service_original_name") == service_name
+ for svc in agent_services
+ )
+
+ if not service_exists:
+ raise Exception(f"Service {service_name} not found in pykv relations for agent {effective_agent_id}")
+
+ # [pykv 唯一真相源] 从实体层获取服务配置
+ service_manager = self.registry._cache_service_manager
+ service_entity = await service_manager.get_service(service_name)
+
+ if not service_entity:
+ raise Exception(f"Service entity not found in pykv: {service_name}")
+
+ service_config = service_entity.config
+ if not service_config:
+ raise Exception(f"Service configuration is empty in pykv: {service_name}")
+
+ logger.debug(f"[TOOL_EXECUTION] Getting service config from pykv entity layer: {service_name}")
+
+ # 标准化配置并创建 FastMCP 客户端
+ normalized_config = self._normalize_service_config(service_config)
+ client = Client({"mcpServers": {service_name: normalized_config}})
+
+ async with client:
+ # 验证工具存在
+ tools = await client.list_tools()
+
+ # 调试日志:验证工具存在
+ logger.debug(f"[FASTMCP_DEBUG] lookup tool='{tool_name}'")
+ logger.debug(f"[FASTMCP_DEBUG] service='{service_name}' tools:")
+ for i, tool in enumerate(tools):
+ logger.debug(f" {i+1}. {tool.name}")
+
+ # 预设为用户提供的原始名称(应为 FastMCP 原生方法名)
+ effective_tool_name = tool_name
+
+ if not any(t.name == tool_name for t in tools):
+ available = [t.name for t in tools]
+ logger.warning(f"[FASTMCP_DEBUG] not_found tool='{tool_name}' in service='{service_name}'")
+ logger.warning(f"[FASTMCP_DEBUG] available={available}")
+
+ # 一次性自修复:若传入名称被意外加了前缀,尝试以可用列表为准做最长后缀匹配
+ fallback = None
+ for cand in available:
+ if effective_tool_name.endswith(cand):
+ fallback = cand
+ break
+
+ if fallback and any(t.name == fallback for t in tools):
+ logger.warning(f"[FASTMCP_DEBUG] self_repair tool_name: '{tool_name}' -> '{fallback}'")
+ effective_tool_name = fallback
+ else:
+ raise Exception(f"Tool {tool_name} not found in service {service_name}. Available: {available}")
+
+ # 使用 FastMCP 标准执行器执行工具
+ result = await executor.execute_tool(
+ client=client,
+ tool_name=effective_tool_name,
+ arguments=arguments,
+ timeout=timeout,
+ progress_handler=progress_handler,
+ raise_on_error=raise_on_error
+ )
+
+ # 返回 FastMCP 客户端的 CallToolResult(与官方保持一致)
+ logger.info(f"[FASTMCP] call ok tool='{effective_tool_name}' service='{service_name}'")
+ return result
+
+ except Exception as e:
+ logger.error(f"[FASTMCP] call failed tool='{tool_name}' service='{service_name}' error={e}")
+ raise Exception(f"Tool execution failed: {str(e)}")
+
+ async def _execute_tool_with_session(
+ self,
+ session_id: str,
+ service_name: str,
+ tool_name: str,
+ arguments: Dict[str, Any],
+ agent_id: Optional[str],
+ executor,
+ timeout: Optional[float],
+ progress_handler,
+ raise_on_error: bool
+ ) -> Any:
+ """
+ 会话感知的工具执行模式
+
+ 使用缓存的 FastMCP Client 执行工具,实现连接复用和状态保持。
+ 这是解决浏览器会话持久化问题的核心逻辑。
+
+ Args:
+ session_id: 会话标识
+ service_name: 服务名称
+ tool_name: 工具名称
+ arguments: 工具参数
+ agent_id: Agent ID
+ executor: FastMCP 执行器
+ timeout: 超时时间
+ progress_handler: 进度处理器
+ raise_on_error: 是否在错误时抛出异常
+
+ Returns:
+ 工具执行结果
+ """
+ try:
+ # Use session_id to get/create named session (priority), otherwise fallback to default session
+ effective_agent_id = agent_id or self.client_manager.global_agent_store_id
+ session = None
+ try:
+ if hasattr(self.session_manager, 'get_named_session') and session_id:
+ session = self.session_manager.get_named_session(effective_agent_id, session_id)
+ if not session:
+ logger.info(f"[SESSION_EXECUTION] Named session '{session_id}' not found for agent {effective_agent_id}, creating new named session")
+ if hasattr(self.session_manager, 'create_named_session'):
+ session = self.session_manager.create_named_session(effective_agent_id, session_id)
+ if not session:
+ # 回退:使用默认会话
+ session = self.session_manager.get_session(effective_agent_id)
+ if not session:
+ logger.info(f"[SESSION_EXECUTION] Default session not found for agent {effective_agent_id}, creating new session")
+ session = self.session_manager.create_session(effective_agent_id)
+ except Exception as e:
+ logger.error(f"[SESSION_EXECUTION] Error getting/creating session: {e}")
+ # 最后兜底创建一个默认会话
+ session = self.session_manager.create_session(effective_agent_id)
+
+ # Get or create persistent FastMCP Client (refer to langchain_mcp_adapters design)
+ client = session.services.get(service_name)
+ if client is None:
+ logger.info(f"[SESSION_EXECUTION] Service '{service_name}' not bound or client is None, creating persistent client")
+ client = await self._create_persistent_client(session, service_name)
+ else:
+ # 如果已有缓存客户端,但未连接,确保连接可用
+ try:
+ if hasattr(client, 'is_connected') and not client.is_connected():
+ logger.debug(f"[SESSION_EXECUTION] Cached client for '{service_name}' not connected, calling _connect()")
+ await client._connect()
+ except Exception as e:
+ logger.warning(f"[SESSION_EXECUTION] Cached client health check failed for '{service_name}', recreating client: {e}")
+ client = await self._create_persistent_client(session, service_name)
+
+ logger.debug(f"[SESSION_EXECUTION] Reusing cached persistent client for service '{service_name}'")
+
+ # Use persistent connection to execute tool directly (avoid state loss from closing connection on each async with)
+ logger.info(f"[SESSION_EXECUTION] Executing tool '{tool_name}' with persistent client (no async with)")
+
+ import time as _t
+ # 确保连接仍然有效
+ try:
+ if hasattr(client, 'is_connected') and not client.is_connected():
+ t_reconnect0 = _t.perf_counter()
+ await client._connect()
+ t_reconnect1 = _t.perf_counter()
+ logger.debug(f"[TIMING] client._connect() (reconnect): {(t_reconnect1 - t_reconnect0):.3f}s")
+ except Exception as e:
+ logger.warning(f"[SESSION_EXECUTION] Client reconnect check failed: {e}")
+
+ # 验证工具存在
+ t_list0 = _t.perf_counter()
+ tools = await client.list_tools()
+ t_list1 = _t.perf_counter()
+ logger.debug(f"[TIMING] client.list_tools(): {(t_list1 - t_list0):.3f}s")
+
+ if not any(t.name == tool_name for t in tools):
+ available_tools = [t.name for t in tools]
+ #
+ # ()
+ fallback = None
+ for cand in available_tools:
+ if tool_name.endswith(cand):
+ fallback = cand
+ break
+ if fallback and any(t.name == fallback for t in tools):
+ logger.warning(f"[SESSION_EXECUTION] self_repair tool_name: '{tool_name}' -> '{fallback}'")
+ #
+ result = await executor.execute_tool(
+ client=client,
+ tool_name=fallback,
+ arguments=arguments,
+ timeout=timeout,
+ progress_handler=progress_handler,
+ raise_on_error=raise_on_error
+ )
+ logger.info(f"[SESSION_EXECUTION] call ok (repaired) tool='{fallback}' service='{service_name}'")
+ return result
+
+ logger.warning(f"[SESSION_EXECUTION] Tool '{tool_name}' not found in service '{service_name}', available: {available_tools}")
+ #
+ #
+ suggestions = []
+ try:
+ #
+ def score(c: str) -> int:
+ s = 0
+ if c in tool_name or tool_name in c:
+ s += 2
+ if c.startswith(tool_name) or tool_name.startswith(c):
+ s += 1
+ return s
+ suggestions = sorted(available_tools, key=lambda c: (-score(c), len(c)))[:3]
+ except Exception:
+ suggestions = available_tools[:3]
+
+ raise Exception(
+ f"Tool '{tool_name}' not found in service '{service_name}'. "
+ f"Available: {available_tools}. "
+ f"Try one of: {suggestions} or use bare method name without any prefixes."
+ )
+
+ # 使用 FastMCP 标准执行器执行工具(不进入 async with,保持连接)
+ t_exec0 = _t.perf_counter()
+ result = await executor.execute_tool(
+ client=client,
+ tool_name=tool_name,
+ arguments=arguments,
+ timeout=timeout,
+ progress_handler=progress_handler,
+ raise_on_error=raise_on_error
+ )
+ t_exec1 = _t.perf_counter()
+ logger.debug(f"[TIMING] executor.execute_tool(): {(t_exec1 - t_exec0):.3f}s")
+
+ # Update session activity time
+ session.update_activity()
+
+ # Return FastMCP client's CallToolResult (consistent with official implementation)
+ logger.info(f"[SESSION_EXECUTION] Tool '{tool_name}' executed successfully in session mode")
+ return result
+
+ except Exception as e:
+ logger.error(f"[SESSION_EXECUTION] Tool execution failed: {e}")
+ if raise_on_error:
+ raise
+ raise Exception(f"Session tool execution failed: {str(e)}")
+
+ async def _create_persistent_client(self, session, service_name: str):
+ """
+ 创建持久的 FastMCP Client 并缓存到会话中
+
+ 基于 langchain_mcp_adapters 和 FastMCP 源码的正确实现:
+
+ 核心发现:
+ 1. FastMCP Client 支持可重入上下文管理器(multiple async with)
+ 2. 使用引用计数维护连接生命周期
+ 3. 后台任务管理实际 session 连接
+
+ 正确的方法:利用 FastMCP Client 的内置机制,不需要自定义 wrapper
+
+ [pykv 唯一真相源] 从实体层获取服务配置
+
+ Args:
+ session: AgentSession 对象
+ service_name: 服务名称
+
+ Returns:
+ Client: 已连接的 FastMCP Client,支持多次复用
+ """
+ try:
+ # [pykv 唯一真相源] 从实体层获取服务配置
+ service_manager = self.registry._cache_service_manager
+ service_entity = await service_manager.get_service(service_name)
+
+ if not service_entity:
+ raise Exception(f"Service entity not found in pykv: {service_name}")
+
+ service_config = service_entity.config
+ if not service_config:
+ raise Exception(f"Service configuration is empty in pykv: {service_name}")
+
+ # 标准化配置
+ normalized_config = self._normalize_service_config(service_config)
+
+ # Create FastMCP Client (utilize its reentrant feature)
+ client = Client({"mcpServers": {service_name: normalized_config}})
+
+ # Start persistent connection (correct usage of FastMCP Client)
+ # 注意:我们调用_connect()而不是使用async with,这样连接会保持活跃
+ await client._connect()
+
+ # 缓存到会话中
+ session.add_service(service_name, client)
+
+ logger.info(f"[SESSION_EXECUTION] Persistent client created and cached for service '{service_name}'")
+ return client
+
+ except Exception as e:
+ logger.error(f"[SESSION_EXECUTION] Failed to create persistent client for service '{service_name}': {e}")
+ raise
+
+# 这些方法已移除 - 使用FastMCP Client的内置连接管理
+
+ async def cleanup(self):
+ """清理资源"""
+ logger.info("Cleaning up MCP Orchestrator resources...")
+
+ # 清理会话
+ self.session_manager.cleanup_expired_sessions()
+
+ # 旧的监控任务已被废弃,无需停止
+ logger.info("Legacy monitoring tasks were already disabled")
+
+ # 关闭所有客户端连接
+ for name, client in self.clients.items():
+ try:
+ await client.close()
+ except Exception as e:
+ logger.error(f"Error closing client {name}: {e}")
+
+ # 清理所有状态
+ self.clients.clear()
+ # 智能重连管理器已被废弃,无需清理
+
+ logger.info("MCP Orchestrator cleanup completed")
+
+ async def _restart_monitoring_tasks(self):
+ """重启监控任务以应用新配置"""
+ logger.info("Restarting monitoring tasks with new configuration...")
+
+ # 旧的监控任务已被废弃,无需停止
+ logger.info("Legacy monitoring tasks were already disabled")
+
+ # 重新启动监控(现在由ServiceLifecycleManager处理)
+ await self._start_monitoring()
+ logger.info("Monitoring tasks restarted successfully")
diff --git a/src/mcpstore/core/orchestrator/types.py b/src/mcpstore/core/orchestrator/types.py
new file mode 100644
index 00000000..4d86deb4
--- /dev/null
+++ b/src/mcpstore/core/orchestrator/types.py
@@ -0,0 +1,7 @@
+"""
+MCPOrchestrator Types
+Orchestrator-related type definitions
+"""
+
+# Orchestrator-related type definitions can be added here
+# Currently kept simple, reserved for future expansion
diff --git a/src/mcpstore/core/performance/__init__.py b/src/mcpstore/core/performance/__init__.py
new file mode 100644
index 00000000..a2f1eb57
--- /dev/null
+++ b/src/mcpstore/core/performance/__init__.py
@@ -0,0 +1,85 @@
+from collections import defaultdict
+from typing import Dict, Any
+
+from .cache import LRUCache
+from .discovery_cache import ServiceDiscoveryCache
+from .prefetch import PrefetchManager
+
+
+class ConnectionPoolManager:
+ """Simple connection pool skeleton (service-name keyed)."""
+
+ def __init__(self, max_connections: int = 50):
+ self.max_connections = max_connections
+ # Lazy-initialized per-service pools and counters
+ self._pools: Dict[str, Any] = {}
+ self._connection_counts: Dict[str, int] = defaultdict(int)
+
+ # Placeholders for future concrete implementations
+ async def get_connection(self, service_name: str): # pragma: no cover - behavior depends on adapters
+ return None
+
+ async def return_connection(self, service_name: str, connection: Any): # pragma: no cover
+ return None
+
+
+class PerformanceOptimizer:
+ """Core performance optimizer composed from cache / prefetch / pool."""
+
+ def __init__(self):
+ self.service_cache = ServiceDiscoveryCache()
+ self.prefetch_manager = PrefetchManager()
+ self.connection_pool = ConnectionPoolManager()
+ self._metrics: Dict[str, Any] = defaultdict(list)
+
+ def enable_caching(self, patterns: Dict[str, int] = None):
+ # Tool result caching is removed; keep method for compatibility
+ return True
+
+ def record_tool_execution(self, tool_name: str, execution_time: float, success: bool):
+ self._metrics[tool_name].append({
+ "execution_time": execution_time,
+ "success": success,
+ })
+ if len(self._metrics[tool_name]) > 100:
+ self._metrics[tool_name].pop(0)
+
+ def get_performance_summary(self) -> Dict[str, Any]:
+ service_cache_stats = self.service_cache.cache.get_stats()
+ return {
+ "service_cache": {
+ "hit_rate": service_cache_stats.hit_rate,
+ "entries": service_cache_stats.entry_count,
+ },
+ "connection_pools": dict(self.connection_pool._connection_counts),
+ "tool_metrics": {
+ tool: {
+ "avg_execution_time": sum(m["execution_time"] for m in metrics) / len(metrics),
+ "success_rate": sum(1 for m in metrics if m["success"]) / len(metrics),
+ "total_calls": len(metrics),
+ }
+ for tool, metrics in self._metrics.items() if metrics
+ },
+ }
+
+
+_global_performance_optimizer = None
+
+
+def get_performance_optimizer() -> PerformanceOptimizer:
+ global _global_performance_optimizer
+ if _global_performance_optimizer is None:
+ _global_performance_optimizer = PerformanceOptimizer()
+ return _global_performance_optimizer
+
+
+__all__ = [
+ "LRUCache",
+ "ServiceDiscoveryCache",
+ "PrefetchManager",
+ "ConnectionPoolManager",
+ "PerformanceOptimizer",
+ "get_performance_optimizer",
+]
+
+
diff --git a/src/mcpstore/core/performance/cache.py b/src/mcpstore/core/performance/cache.py
new file mode 100644
index 00000000..65f89913
--- /dev/null
+++ b/src/mcpstore/core/performance/cache.py
@@ -0,0 +1,131 @@
+import logging
+import pickle
+from collections import OrderedDict
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class CacheStrategy(Enum):
+ """Cache strategies (reserved for future extension)."""
+ LRU = "lru"
+ LFU = "lfu"
+ TTL = "ttl"
+ ADAPTIVE = "adaptive"
+
+
+@dataclass
+class CacheEntry:
+ key: str
+ value: Any
+ created_at: datetime
+ last_accessed: datetime
+ access_count: int = 0
+ ttl: Optional[int] = None
+ size: int = 0
+
+
+@dataclass
+class CacheStats:
+ hits: int = 0
+ misses: int = 0
+ evictions: int = 0
+ total_size: int = 0
+ entry_count: int = 0
+
+ @property
+ def hit_rate(self) -> float:
+ total = self.hits + self.misses
+ return self.hits / total if total > 0 else 0.0
+
+
+class LRUCache:
+ """LRU cache with optional TTL per entry and total size accounting."""
+
+ def __init__(self, max_size: int = 1000, max_memory: int = 100 * 1024 * 1024):
+ self.max_size = max_size
+ self.max_memory = max_memory
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
+ self._stats = CacheStats()
+
+ def get(self, key: str) -> Optional[Any]:
+ if key in self._cache:
+ entry = self._cache[key]
+ if entry.ttl and (datetime.now() - entry.created_at).total_seconds() > entry.ttl:
+ self._evict(key)
+ self._stats.misses += 1
+ return None
+
+ entry.last_accessed = datetime.now()
+ entry.access_count += 1
+ self._cache.move_to_end(key)
+ self._stats.hits += 1
+ return entry.value
+
+ self._stats.misses += 1
+ return None
+
+ def put(self, key: str, value: Any, ttl: Optional[int] = None):
+ size = self._calculate_size(value)
+ while (len(self._cache) >= self.max_size or
+ self._stats.total_size + size > self.max_memory):
+ if not self._cache:
+ break
+ self._evict_lru()
+
+ entry = CacheEntry(
+ key=key,
+ value=value,
+ created_at=datetime.now(),
+ last_accessed=datetime.now(),
+ ttl=ttl,
+ size=size,
+ )
+
+ if key in self._cache:
+ old_entry = self._cache[key]
+ self._stats.total_size -= old_entry.size
+
+ self._cache[key] = entry
+ self._stats.total_size += size
+ self._stats.entry_count = len(self._cache)
+
+ def _evict_lru(self):
+ if self._cache:
+ key, entry = self._cache.popitem(last=False)
+ self._stats.total_size -= entry.size
+ self._stats.evictions += 1
+ logger.debug(f"Evicted LRU cache entry: {key}")
+
+ def _evict(self, key: str):
+ if key in self._cache:
+ entry = self._cache.pop(key)
+ self._stats.total_size -= entry.size
+ self._stats.evictions += 1
+
+ def _calculate_size(self, value: Any) -> int:
+ try:
+ return len(pickle.dumps(value))
+ except Exception:
+ return len(str(value).encode('utf-8'))
+
+ def clear(self):
+ self._cache.clear()
+ self._stats = CacheStats()
+
+ def get_stats(self) -> CacheStats:
+ self._stats.entry_count = len(self._cache)
+ return self._stats
+
+
+__all__ = [
+ "CacheStrategy",
+ "CacheEntry",
+ "CacheStats",
+ "LRUCache",
+]
+
+
diff --git a/src/mcpstore/core/performance/discovery_cache.py b/src/mcpstore/core/performance/discovery_cache.py
new file mode 100644
index 00000000..0794da5a
--- /dev/null
+++ b/src/mcpstore/core/performance/discovery_cache.py
@@ -0,0 +1,30 @@
+from typing import Dict, List, Any, Optional
+
+from .cache import LRUCache
+
+
+class ServiceDiscoveryCache:
+ """Cache for service info and tool lists with TTL semantics."""
+
+ def __init__(self, ttl: int = 300): # 5 minutes
+ self.cache = LRUCache(max_size=100)
+ self.ttl = ttl
+
+ def get_service_info(self, service_name: str) -> Optional[Dict[str, Any]]:
+ return self.cache.get(f"service:{service_name}")
+
+ def cache_service_info(self, service_name: str, service_info: Dict[str, Any]):
+ self.cache.put(f"service:{service_name}", service_info, self.ttl)
+
+ def get_tools_for_service(self, service_name: str) -> Optional[List[Dict[str, Any]]]:
+ return self.cache.get(f"tools:{service_name}")
+
+ def cache_tools_for_service(self, service_name: str, tools: List[Dict[str, Any]]):
+ self.cache.put(f"tools:{service_name}", tools, self.ttl)
+
+
+__all__ = [
+ "ServiceDiscoveryCache",
+]
+
+
diff --git a/src/mcpstore/core/performance/prefetch.py b/src/mcpstore/core/performance/prefetch.py
new file mode 100644
index 00000000..d0cbd3f8
--- /dev/null
+++ b/src/mcpstore/core/performance/prefetch.py
@@ -0,0 +1,37 @@
+import asyncio
+import logging
+from typing import Dict, Any
+
+logger = logging.getLogger(__name__)
+
+
+class PrefetchManager:
+ """Lightweight async prefetch queue runner."""
+
+ def __init__(self):
+ self._prefetch_queue: asyncio.Queue = asyncio.Queue()
+ self._running = False
+
+ async def start_prefetch_worker(self):
+ self._running = True
+ while self._running:
+ try:
+ prefetch_task = await asyncio.wait_for(self._prefetch_queue.get(), timeout=1.0)
+ await self._execute_prefetch(prefetch_task)
+ except asyncio.TimeoutError:
+ continue
+ except Exception as e:
+ logger.error(f"Prefetch error: {e}")
+
+ def stop_prefetch_worker(self):
+ self._running = False
+
+ async def _execute_prefetch(self, task: Dict[str, Any]):
+ logger.debug(f"Executing prefetch task: {task}")
+
+
+__all__ = [
+ "PrefetchManager",
+]
+
+
diff --git a/src/mcpstore/core/registry.py b/src/mcpstore/core/registry.py
deleted file mode 100644
index d323add1..00000000
--- a/src/mcpstore/core/registry.py
+++ /dev/null
@@ -1,341 +0,0 @@
-import os, sys
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-import logging
-from datetime import datetime
-from typing import Dict, Any, Optional, Tuple, List, Set, TypeVar, Generic, Protocol
-
-logger = logging.getLogger(__name__)
-
-# 定义一个协议,表示任何具有call_tool方法的会话类型
-class SessionProtocol(Protocol):
- async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
- ...
-
-# 会话类型变量
-SessionType = TypeVar('SessionType')
-
-class ServiceRegistry:
- """
- Manages the state of connected services and their tools, with agent_id isolation.
-
- agent_id 作为一级 key,实现 store/agent/agent 之间的完全隔离:
- - self.sessions: Dict[agent_id, Dict[service_name, session]]
- - self.tool_cache: Dict[agent_id, Dict[tool_name, tool_def]]
- - self.tool_to_session_map: Dict[agent_id, Dict[tool_name, session]]
- - self.service_health: Dict[agent_id, Dict[service_name, last_heartbeat]]
- 所有操作都必须带 agent_id,store 级别用 main_client,agent 级别用实际 agent_id。
- """
- def __init__(self):
- # agent_id -> {service_name: session}
- self.sessions: Dict[str, Dict[str, Any]] = {}
- # agent_id -> {service_name: last_heartbeat_time}
- self.service_health: Dict[str, Dict[str, datetime]] = {}
- # agent_id -> {tool_name: tool_definition}
- self.tool_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
- # agent_id -> {tool_name: session}
- self.tool_to_session_map: Dict[str, Dict[str, Any]] = {}
- logger.info("ServiceRegistry initialized (multi-context isolation).")
-
- def clear(self, agent_id: str):
- """
- 清空指定 agent_id 的所有注册服务和工具。
- 只影响该 agent_id 下的服务、工具、会话,不影响其它 agent。
- """
- self.sessions.pop(agent_id, None)
- self.service_health.pop(agent_id, None)
- self.tool_cache.pop(agent_id, None)
- self.tool_to_session_map.pop(agent_id, None)
-
- def add_service(self, agent_id: str, name: str, session: Any, tools: List[Tuple[str, Dict[str, Any]]]) -> List[str]:
- """
- 为指定 agent_id 注册服务及其工具。
- - agent_id: store/agent 的唯一标识
- - name: 服务名
- - session: 服务会话对象
- - tools: [(tool_name, tool_def)]
- 返回实际注册的工具名列表。
- """
- if agent_id not in self.sessions:
- self.sessions[agent_id] = {}
- if agent_id not in self.service_health:
- self.service_health[agent_id] = {}
- if agent_id not in self.tool_cache:
- self.tool_cache[agent_id] = {}
- if agent_id not in self.tool_to_session_map:
- self.tool_to_session_map[agent_id] = {}
-
- # 只在首次注册时打印日志
- if name not in self.sessions[agent_id]:
- print(f"[DEBUG][add_service] 首次注册服务 - agent_id={agent_id}, name={name}")
-
- if name in self.sessions[agent_id]:
- logger.warning(f"Attempting to add already registered service: {name} for agent {agent_id}. Removing old service before overwriting.")
- self.remove_service(agent_id, name)
-
- self.sessions[agent_id][name] = session
- self.service_health[agent_id][name] = datetime.now() # Mark healthy on add
- added_tool_names = []
- for tool_name, tool_definition in tools:
- if not tool_name.startswith(f"{name}_"):
- logger.warning(f"Tool '{tool_name}' does not belong to service '{name}'. Skipping this tool.")
- continue
- if tool_name in self.tool_cache[agent_id]:
- existing_session = self.tool_to_session_map[agent_id].get(tool_name)
- if existing_session is not session:
- logger.warning(f"Tool name conflict: '{tool_name}' from {name} for agent {agent_id} conflicts with existing tool. Skipping this tool.")
- continue
- self.tool_cache[agent_id][tool_name] = tool_definition
- self.tool_to_session_map[agent_id][tool_name] = session
- added_tool_names.append(tool_name)
- logger.info(f"Service '{name}' for agent '{agent_id}' added with tools: {added_tool_names}")
- return added_tool_names
-
- def remove_service(self, agent_id: str, name: str) -> Optional[Any]:
- """
- 移除指定 agent_id 下的服务及其所有工具。
- 只影响该 agent_id,不影响其它 agent。
- """
- session = self.sessions.get(agent_id, {}).pop(name, None)
- if not session:
- logger.warning(f"Attempted to remove non-existent service: {name} for agent {agent_id}")
- return None
- if agent_id in self.service_health and name in self.service_health[agent_id]:
- del self.service_health[agent_id][name]
- # Remove associated tools efficiently
- tools_to_remove = [tool_name for tool_name, owner_session in self.tool_to_session_map.get(agent_id, {}).items() if owner_session is session]
- for tool_name in tools_to_remove:
- if tool_name in self.tool_cache.get(agent_id, {}): del self.tool_cache[agent_id][tool_name]
- if tool_name in self.tool_to_session_map.get(agent_id, {}): del self.tool_to_session_map[agent_id][tool_name]
- logger.info(f"Service '{name}' for agent '{agent_id}' removed from registry.")
- return session
-
- def get_session(self, agent_id: str, name: str) -> Optional[Any]:
- """
- 获取指定 agent_id 下的服务会话。
- """
- return self.sessions.get(agent_id, {}).get(name)
-
- def get_session_for_tool(self, agent_id: str, tool_name: str) -> Optional[Any]:
- """
- 获取指定 agent_id 下工具对应的服务会话。
- """
- return self.tool_to_session_map.get(agent_id, {}).get(tool_name)
-
- def get_all_tools(self, agent_id: str) -> List[Dict[str, Any]]:
- """
- 获取指定 agent_id 下所有工具的定义。
- """
- all_tools = []
- for tool_name, tool_def in self.tool_cache.get(agent_id, {}).items():
- session = self.tool_to_session_map.get(agent_id, {}).get(tool_name)
- service_name = None
- for name, sess in self.sessions.get(agent_id, {}).items():
- if sess is session:
- service_name = name
- break
- tool_with_service = tool_def.copy()
- if "function" not in tool_with_service and isinstance(tool_with_service, dict):
- tool_with_service = {
- "type": "function",
- "function": tool_with_service
- }
- if "function" in tool_with_service:
- function_data = tool_with_service["function"]
- if service_name:
- original_description = function_data.get("description", "")
- if not original_description.endswith(f" (来自服务: {service_name})"):
- function_data["description"] = f"{original_description} (来自服务: {service_name})"
- function_data["service_info"] = {"service_name": service_name}
- all_tools.append(tool_with_service)
- logger.info(f"Returning {len(all_tools)} tools from {len(self.get_all_service_names(agent_id))} services for agent {agent_id}")
- return all_tools
-
- def get_all_tool_info(self, agent_id: str) -> List[Dict[str, Any]]:
- """
- 获取指定 agent_id 下所有工具的详细信息。
- """
- tools_info = []
- for tool_name in self.tool_cache.get(agent_id, {}).keys():
- session = self.tool_to_session_map.get(agent_id, {}).get(tool_name)
- service_name = None
- for name, sess in self.sessions.get(agent_id, {}).items():
- if sess is session:
- service_name = name
- break
- detailed_tool = self._get_detailed_tool_info(agent_id, tool_name)
- if detailed_tool:
- detailed_tool["service_name"] = service_name
- tools_info.append(detailed_tool)
- return tools_info
-
- def get_connected_services(self, agent_id: str) -> List[Dict[str, Any]]:
- """
- 获取指定 agent_id 下所有已连接服务的信息。
- """
- services = []
- for name in self.get_all_service_names(agent_id):
- tools = self.get_tools_for_service(agent_id, name)
- services.append({
- "name": name,
- "tool_count": len(tools)
- })
- return services
-
- def get_tools_for_service(self, agent_id: str, name: str) -> List[str]:
- """
- 获取指定 agent_id 下某服务的所有工具名。
- """
- session = self.sessions.get(agent_id, {}).get(name)
- logger.info(f"Getting tools for service: {name} (agent_id={agent_id})")
-
- # 只在调试特定问题时打印详细日志
- if logger.getEffectiveLevel() <= logging.DEBUG:
- print(f"[DEBUG][get_tools_for_service] agent_id={agent_id}, name={name}, id(session)={id(session) if session else None}")
-
- if not session:
- return []
-
- tools = [tool_name for tool_name in self.tool_cache.get(agent_id, {}).keys() if tool_name.startswith(f"{name}_")]
- return tools
-
- def _extract_description_from_schema(self, prop_info):
- """从 schema 中提取描述信息"""
- if isinstance(prop_info, dict):
- # 优先查找 description 字段
- if 'description' in prop_info:
- return prop_info['description']
- # 其次查找 title 字段
- elif 'title' in prop_info:
- return prop_info['title']
- # 检查是否有 anyOf 或 allOf 结构
- elif 'anyOf' in prop_info:
- for item in prop_info['anyOf']:
- if isinstance(item, dict) and 'description' in item:
- return item['description']
- elif 'allOf' in prop_info:
- for item in prop_info['allOf']:
- if isinstance(item, dict) and 'description' in item:
- return item['description']
-
- return "无描述"
-
- def _extract_type_from_schema(self, prop_info):
- """从 schema 中提取类型信息"""
- if isinstance(prop_info, dict):
- if 'type' in prop_info:
- return prop_info['type']
- elif 'anyOf' in prop_info:
- # 处理 Union 类型
- types = []
- for item in prop_info['anyOf']:
- if isinstance(item, dict) and 'type' in item:
- types.append(item['type'])
- return '|'.join(types) if types else '未知'
- elif 'allOf' in prop_info:
- # 处理 intersection 类型
- for item in prop_info['allOf']:
- if isinstance(item, dict) and 'type' in item:
- return item['type']
-
- return "未知"
-
- def _get_detailed_tool_info(self, agent_id: str, tool_name: str) -> Dict[str, Any]:
- """
- 获取指定 agent_id 下某工具的详细信息。
- """
- tool_def = self.tool_cache.get(agent_id, {}).get(tool_name)
- if not tool_def:
- return {}
- session = self.tool_to_session_map.get(agent_id, {}).get(tool_name)
- service_name = None
- if session:
- for name, sess in self.sessions.get(agent_id, {}).items():
- if sess is session:
- service_name = name
- break
- if "function" in tool_def:
- function_data = tool_def["function"]
- tool_info = {
- "name": tool_name,
- "description": function_data.get("description", ""),
- "service_name": service_name,
- "inputSchema": function_data.get("parameters", {})
- }
- else:
- tool_info = {
- "name": tool_name,
- "description": tool_def.get("description", ""),
- "service_name": service_name,
- "inputSchema": tool_def.get("parameters", {})
- }
- return tool_info
-
- def get_service_details(self, agent_id: str, name: str) -> Dict[str, Any]:
- """
- 获取指定 agent_id 下某服务的详细信息。
- """
- if name not in self.sessions.get(agent_id, {}):
- return {}
-
- logger.info(f"Getting service details for: {name} (agent_id={agent_id})")
- session = self.sessions.get(agent_id, {}).get(name)
-
- # 只在调试特定问题时打印详细日志
- if logger.getEffectiveLevel() <= logging.DEBUG:
- print(f"[DEBUG][get_service_details] agent_id={agent_id}, name={name}, id(session)={id(session) if session else None}")
-
- tools = self.get_tools_for_service(agent_id, name)
- last_heartbeat = self.service_health.get(agent_id, {}).get(name)
- detailed_tools = []
- for tool_name in tools:
- detailed_tool = self._get_detailed_tool_info(agent_id, tool_name)
- if detailed_tool:
- detailed_tools.append(detailed_tool)
- return {
- "name": name,
- "tools": detailed_tools,
- "tool_count": len(tools),
- "last_heartbeat": str(last_heartbeat) if last_heartbeat else "N/A",
- "connected": name in self.sessions.get(agent_id, {})
- }
-
- def get_all_service_names(self, agent_id: str) -> List[str]:
- """
- 获取指定 agent_id 下所有已注册服务名。
- """
- return list(self.sessions.get(agent_id, {}).keys())
-
- def update_service_health(self, agent_id: str, name: str):
- """
- 更新指定 agent_id 下某服务的心跳时间。
- """
- if name in self.sessions.get(agent_id, {}):
- if agent_id not in self.service_health:
- self.service_health[agent_id] = {}
- self.service_health[agent_id][name] = datetime.now()
- logger.debug(f"Health updated for service: {name} (agent_id={agent_id})")
-
- def get_last_heartbeat(self, agent_id: str, name: str) -> Optional[datetime]:
- """
- 获取指定 agent_id 下某服务的最后心跳时间。
- """
- return self.service_health.get(agent_id, {}).get(name)
-
- def has_service(self, agent_id: str, name: str) -> bool:
- """
- 判断指定 agent_id 下是否存在某服务。
- """
- return name in self.sessions.get(agent_id, {})
-
- def get_service_config(self, agent_id: str, name: str) -> Optional[Dict[str, Any]]:
- """获取服务配置"""
- if not self.has_service(agent_id, name):
- return None
-
- # 从 orchestrator 的 mcp_config 获取配置
- from api.deps import app_state
- orchestrator = app_state.get("orchestrator")
- if orchestrator and orchestrator.mcp_config:
- return orchestrator.mcp_config.get_service_config(name)
-
- return None
diff --git a/src/mcpstore/core/registry/__init__.py b/src/mcpstore/core/registry/__init__.py
new file mode 100644
index 00000000..cb445c1b
--- /dev/null
+++ b/src/mcpstore/core/registry/__init__.py
@@ -0,0 +1,52 @@
+"""
+MCPStore Registry Module
+Registry module - Unified management of service registration, tool resolution, Schema management and other functions
+
+Refactoring notes:
+- Unified previously scattered registration-related files into registry/ module
+- Maintains 100% backward compatibility, all existing import paths remain valid
+- Centralized function management for easier maintenance and extension
+
+Module structure:
+- core_registry.py: Core service registry (original registry.py)
+- tool_resolver.py: Tool name resolver
+- types.py: Registration-related type definitions
+"""
+
+__all__ = [
+ # Core registry
+ 'ServiceRegistry',
+ 'SessionProtocol',
+ 'SessionType',
+
+ # Tool resolution
+ 'ToolNameResolver',
+ 'ToolResolution',
+
+ # Type definitions
+ 'RegistryTypes',
+
+ # Compatibility exports
+ 'ServiceConnectionState',
+ 'ServiceStateMetadata'
+]
+
+# Main exports - maintain backward compatibility
+# Import from the new modular core_registry
+from .core_registry import ServiceRegistry
+# SchemaManager removed in single-source mode; no longer exported
+from .tool_resolver import ToolNameResolver, ToolResolution
+# Protocols and type helpers are defined in types module
+from .types import SessionProtocol, SessionType, RegistryTypes
+
+# 导出常用类型
+try:
+ from ..models.service import ServiceConnectionState, ServiceStateMetadata
+ __all__.extend(['ServiceConnectionState', 'ServiceStateMetadata'])
+except ImportError:
+ pass
+
+# Version information
+__version__ = "1.0.0"
+__author__ = "MCPStore Team"
+__description__ = "Registry module for MCPStore - Service registration, tool resolution, and schema management"
diff --git a/src/mcpstore/core/registry/agent_locks.py b/src/mcpstore/core/registry/agent_locks.py
new file mode 100644
index 00000000..1c90e722
--- /dev/null
+++ b/src/mcpstore/core/registry/agent_locks.py
@@ -0,0 +1,337 @@
+"""
+AgentLocks - Per-agent 异步读写锁
+
+提供细粒度的并发控制,确保同一 agent 的操作串行执行,
+不同 agent 的操作可以并行执行。
+
+设计原则:
+1. 每个 agent_id 拥有独立的锁,避免全局锁竞争
+2. 支持读写锁语义(当前实现为写锁,可扩展为读写锁)
+3. 提供诊断能力,便于排查死锁和性能问题
+4. 懒加载锁实例,避免内存浪费
+"""
+
+import asyncio
+import logging
+import time
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from typing import Dict, AsyncIterator, Optional, Set
+
+from mcpstore.core.bridge import get_async_bridge
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class LockStats:
+ """锁统计信息"""
+ agent_id: str
+ acquired_count: int = 0
+ total_wait_time_ms: float = 0.0
+ max_wait_time_ms: float = 0.0
+ current_holder: Optional[str] = None
+ waiting_count: int = 0
+
+
+@dataclass
+class LockContext:
+ """锁上下文信息,用于诊断"""
+ agent_id: str
+ operation: str
+ acquired_at: float
+ caller: str = ""
+
+
+class AgentLocks:
+ """
+ Per-agent 异步锁管理器
+
+ 提供细粒度的并发控制:
+ - 同一 agent_id 的操作串行执行
+ - 不同 agent_id 的操作可以并行执行
+ - 支持诊断和监控
+
+ 使用示例:
+ async with locks.write(agent_id, operation="update_cache"):
+ # 多步骤缓存更新操作
+ await step1()
+ await step2()
+ """
+
+ def __init__(self, enable_diagnostics: bool = True) -> None:
+ """
+ 初始化锁管理器
+
+ Args:
+ enable_diagnostics: 是否启用诊断功能(记录等待时间、持有者等)
+ """
+ self._bridge = get_async_bridge()
+ self._bridge_loop = getattr(self._bridge, "_loop", None)
+ # 每个 agent_id 对应一个锁
+ self._locks: Dict[str, asyncio.Lock] = {}
+ # 全局锁,用于保护 _locks 字典的创建
+ self._global_lock = asyncio.Lock()
+ # 诊断开关
+ self._enable_diagnostics = enable_diagnostics
+ # 锁统计信息
+ self._stats: Dict[str, LockStats] = {}
+ # 当前持有锁的上下文
+ self._active_contexts: Dict[str, LockContext] = {}
+ # 等待锁的操作集合
+ self._waiting: Dict[str, Set[str]] = {}
+
+ logger.debug("[AgentLocks] Initialization completed, diagnostics enabled: %s", enable_diagnostics)
+
+ async def _ensure_lock(self, agent_id: str) -> asyncio.Lock:
+ """
+ 确保指定 agent_id 的锁存在(懒加载)
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 对应的 asyncio.Lock 实例
+ """
+ # 快速路径:锁已存在
+ lock = self._locks.get(agent_id)
+ if lock is not None:
+ return lock
+
+ # 慢路径:需要创建锁
+ async with self._global_lock:
+ # 双重检查
+ if agent_id not in self._locks:
+ # 在有事件循环的线程直接创建锁,避免在运行中的 loop 上调用 bridge.run 触发 RuntimeError
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if running_loop:
+ lock = asyncio.Lock()
+ elif self._bridge_loop and self._bridge_loop.is_running():
+ async def _create_lock():
+ return asyncio.Lock()
+ lock = self._bridge.run(_create_lock(), op_name="agent_locks.create_lock")
+ else:
+ lock = asyncio.Lock()
+ self._locks[agent_id] = lock
+ if self._enable_diagnostics:
+ self._stats[agent_id] = LockStats(agent_id=agent_id)
+ self._waiting[agent_id] = set()
+ logger.debug("[AgentLocks] Creating new lock for agent_id=%s", agent_id)
+ return self._locks[agent_id]
+
+ @asynccontextmanager
+ async def write(
+ self,
+ agent_id: str,
+ operation: str = "unknown",
+ timeout: Optional[float] = None
+ ) -> AsyncIterator[None]:
+ """
+ 获取写锁(独占锁)
+
+ Args:
+ agent_id: Agent ID
+ operation: 操作名称(用于诊断)
+ timeout: 超时时间(秒),None 表示无限等待
+
+ Yields:
+ None
+
+ Raises:
+ asyncio.TimeoutError: 如果指定了 timeout 且超时
+
+ 使用示例:
+ async with locks.write(agent_id, operation="update_service_status"):
+ await update_status()
+ """
+ lock = await self._ensure_lock(agent_id)
+ start_time = time.monotonic()
+ operation_id = f"{operation}_{id(asyncio.current_task())}"
+
+ # 记录等待状态
+ if self._enable_diagnostics:
+ self._waiting.setdefault(agent_id, set()).add(operation_id)
+ if self._stats.get(agent_id):
+ self._stats[agent_id].waiting_count = len(self._waiting[agent_id])
+
+ try:
+ # 获取锁(支持超时)
+ async def _acquire():
+ if timeout is not None:
+ await asyncio.wait_for(lock.acquire(), timeout=timeout)
+ else:
+ await lock.acquire()
+
+ # 在锁所属的桥接 loop 上执行,避免跨 loop 错误
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if self._bridge_loop and running_loop is not self._bridge_loop:
+ await asyncio.to_thread(
+ self._bridge.run,
+ _acquire(),
+ op_name=f"agent_locks.acquire.{agent_id}"
+ )
+ else:
+ await _acquire()
+
+ # 记录诊断信息
+ wait_time_ms = (time.monotonic() - start_time) * 1000
+ if self._enable_diagnostics:
+ self._waiting[agent_id].discard(operation_id)
+ stats = self._stats.get(agent_id)
+ if stats:
+ stats.acquired_count += 1
+ stats.total_wait_time_ms += wait_time_ms
+ stats.max_wait_time_ms = max(stats.max_wait_time_ms, wait_time_ms)
+ stats.current_holder = operation
+ stats.waiting_count = len(self._waiting[agent_id])
+
+ self._active_contexts[agent_id] = LockContext(
+ agent_id=agent_id,
+ operation=operation,
+ acquired_at=time.monotonic()
+ )
+
+ # 如果等待时间过长,记录警告
+ if wait_time_ms > 100: # 超过 100ms
+ logger.warning(
+ "[AgentLocks] Lock wait time too long: agent_id=%s, operation=%s, wait_time=%.2fms",
+ agent_id, operation, wait_time_ms
+ )
+ else:
+ logger.debug(
+ "[AgentLocks] Lock acquired successfully: agent_id=%s, operation=%s, wait_time=%.2fms",
+ agent_id, operation, wait_time_ms
+ )
+
+ yield
+
+ finally:
+ # 释放锁
+ async def _release():
+ lock.release()
+
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if self._bridge_loop and running_loop is not self._bridge_loop:
+ await asyncio.to_thread(
+ self._bridge.run,
+ _release(),
+ op_name=f"agent_locks.release.{agent_id}"
+ )
+ else:
+ await _release()
+
+ # 清理诊断信息
+ if self._enable_diagnostics:
+ self._waiting.get(agent_id, set()).discard(operation_id)
+ if agent_id in self._active_contexts:
+ ctx = self._active_contexts.pop(agent_id)
+ hold_time_ms = (time.monotonic() - ctx.acquired_at) * 1000
+ if hold_time_ms > 500: # 持有超过 500ms
+ logger.warning(
+ "[AgentLocks] Lock hold time too long: agent_id=%s, operation=%s, hold_time=%.2fms",
+ agent_id, operation, hold_time_ms
+ )
+
+ stats = self._stats.get(agent_id)
+ if stats:
+ stats.current_holder = None
+ stats.waiting_count = len(self._waiting.get(agent_id, set()))
+
+ logger.debug("[AgentLocks] Releasing lock: agent_id=%s, operation=%s", agent_id, operation)
+
+ def get_stats(self, agent_id: Optional[str] = None) -> Dict[str, LockStats]:
+ """
+ 获取锁统计信息
+
+ Args:
+ agent_id: 指定 agent_id,None 表示获取所有
+
+ Returns:
+ 锁统计信息字典
+ """
+ if not self._enable_diagnostics:
+ return {}
+
+ if agent_id:
+ stats = self._stats.get(agent_id)
+ return {agent_id: stats} if stats else {}
+
+ return dict(self._stats)
+
+ def get_active_locks(self) -> Dict[str, LockContext]:
+ """
+ 获取当前持有的锁信息
+
+ Returns:
+ 当前活跃的锁上下文字典
+ """
+ if not self._enable_diagnostics:
+ return {}
+ return dict(self._active_contexts)
+
+ def is_locked(self, agent_id: str) -> bool:
+ """
+ 检查指定 agent_id 的锁是否被持有
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ True 如果锁被持有
+ """
+ lock = self._locks.get(agent_id)
+ return lock is not None and lock.locked()
+
+ def get_waiting_count(self, agent_id: str) -> int:
+ """
+ 获取等待指定锁的操作数量
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 等待中的操作数量
+ """
+ return len(self._waiting.get(agent_id, set()))
+
+ def cleanup(self, agent_id: str) -> None:
+ """
+ 清理指定 agent_id 的锁资源
+
+ 注意:只有在确定该 agent 不再使用时才调用
+
+ Args:
+ agent_id: Agent ID
+ """
+ lock = self._locks.pop(agent_id, None)
+ if lock and lock.locked():
+ logger.warning(
+ "[AgentLocks] Lock still held during cleanup: agent_id=%s",
+ agent_id
+ )
+
+ self._stats.pop(agent_id, None)
+ self._active_contexts.pop(agent_id, None)
+ self._waiting.pop(agent_id, None)
+
+ logger.debug("[AgentLocks] Cleaning up lock resources: agent_id=%s", agent_id)
+
+ def __repr__(self) -> str:
+ active_count = sum(1 for lock in self._locks.values() if lock.locked())
+ return (
+ f"AgentLocks(total={len(self._locks)}, "
+ f"active={active_count}, "
+ f"diagnostics={self._enable_diagnostics})"
+ )
diff --git a/src/mcpstore/core/registry/atomic.py b/src/mcpstore/core/registry/atomic.py
new file mode 100644
index 00000000..02fb59c2
--- /dev/null
+++ b/src/mcpstore/core/registry/atomic.py
@@ -0,0 +1,216 @@
+"""
+Atomic write utilities for MCPStore cache backends.
+
+Provides:
+- @atomic_write decorator (async + sync) that wraps a method with:
+ * optional per-agent write lock
+ * backend.begin()/commit()/rollback()
+- Async/sync context managers for manual composition
+
+Design goals:
+- Zero coupling to Redis. Works with any CacheBackend implementation
+- Require only that the wrapped method's `self` provides either
+ * self.cache_backend, or
+ * self.registry.cache_backend
+- Agent-level isolation via per-agent locks (asyncio for async, threading for sync)
+"""
+from __future__ import annotations
+
+import asyncio
+import functools
+import inspect
+import threading
+from typing import Any, Callable, Optional, Dict
+
+
+class AtomicWriteError(RuntimeError):
+ pass
+
+
+class AtomicWriteLocks:
+ """Async per-agent locks.
+
+ Stored on an owning object as `._atomic_write_locks`.
+
+ NOTE: This class is now DEPRECATED in favor of AgentLocks.
+ It's kept for backward compatibility but should not be used in new code.
+ """
+
+ def __init__(self) -> None:
+ self._locks: Dict[str, asyncio.Lock] = {}
+ # FIX: Use threading.Lock instead of asyncio.Lock for thread-safe creation
+ self._global_lock = threading.Lock()
+
+ def get(self, agent_id: str) -> asyncio.Lock:
+ # Fast path if present
+ lock = self._locks.get(agent_id)
+ if lock is not None:
+ return lock
+
+ # FIX: Use threading lock to avoid deadlock when called from running event loop
+ # This is safe because we're only protecting the dictionary mutation, not async operations
+ with self._global_lock:
+ # Double-check pattern
+ lk = self._locks.get(agent_id)
+ if lk is None:
+ lk = asyncio.Lock()
+ self._locks[agent_id] = lk
+ return lk
+
+
+class ThreadWriteLocks:
+ """Sync per-agent locks based on threading.Lock.
+
+ Stored on an owning object as `._atomic_write_thread_locks`.
+ """
+
+ def __init__(self) -> None:
+ self._locks: Dict[str, threading.Lock] = {}
+ self._global = threading.Lock()
+
+ def get(self, agent_id: str) -> threading.Lock:
+ lk = self._locks.get(agent_id)
+ if lk is not None:
+ return lk
+ with self._global:
+ lk = self._locks.get(agent_id)
+ if lk is None:
+ lk = threading.Lock()
+ self._locks[agent_id] = lk
+ return lk
+
+
+def _resolve_backend(owner: Any):
+ """Try to resolve a CacheBackend from an owner object."""
+ be = getattr(owner, "cache_backend", None)
+ if be is not None:
+ return be
+ registry = getattr(owner, "registry", None)
+ if registry is not None:
+ be = getattr(registry, "cache_backend", None)
+ if be is not None:
+ return be
+ raise AtomicWriteError("atomic_write: cannot resolve cache_backend from owner. Expected 'self.cache_backend' or 'self.registry.cache_backend'.")
+
+
+def _resolve_agent_id(fn: Callable[..., Any], args: tuple, kwargs: dict, param_name: str) -> Optional[str]:
+ """Extract agent_id from function arguments by name."""
+ try:
+ sig = inspect.signature(fn)
+ bound = sig.bind_partial(*args, **kwargs)
+ bound.apply_defaults()
+ if param_name in bound.arguments:
+ return bound.arguments[param_name]
+ except Exception:
+ pass
+ return kwargs.get(param_name)
+
+
+def atomic_write(agent_id_param: str = "agent_id", use_lock: bool = True):
+ """Decorator to make a method execute as an atomic write transaction.
+
+ Behavior:
+ - Resolve backend from `self.cache_backend` or `self.registry.cache_backend`
+ - Optionally acquire per-agent write lock keyed by agent_id
+ - Call backend.begin(); execute the function; backend.commit(); on error backend.rollback()
+
+ Works with both async and sync methods.
+
+ IMPORTANT: When use_lock=True for sync methods called from async contexts,
+ the decorator will skip internal locking and rely on external AgentLocks to avoid deadlock.
+ """
+
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
+ is_async = inspect.iscoroutinefunction(fn)
+
+ if is_async:
+ @functools.wraps(fn)
+ async def async_wrapper(*args, **kwargs):
+ if not args:
+ raise AtomicWriteError("@atomic_write must decorate a bound method (expects 'self' as first arg)")
+ owner = args[0]
+ backend = _resolve_backend(owner)
+
+ agent_id = _resolve_agent_id(fn, args, kwargs, agent_id_param)
+ if use_lock and agent_id:
+ locks: AtomicWriteLocks = getattr(owner, "_atomic_write_locks", None) # type: ignore[assignment]
+ if locks is None:
+ locks = AtomicWriteLocks()
+ setattr(owner, "_atomic_write_locks", locks)
+ lock = locks.get(str(agent_id))
+ else:
+ lock = None
+
+ async def _do():
+ backend.begin()
+ try:
+ result = await fn(*args, **kwargs)
+ backend.commit()
+ return result
+ except Exception:
+ try:
+ backend.rollback()
+ finally:
+ pass
+ raise
+
+ if lock is None:
+ return await _do()
+ async with lock:
+ return await _do()
+
+ return async_wrapper
+
+ else:
+ @functools.wraps(fn)
+ def sync_wrapper(*args, **kwargs):
+ if not args:
+ raise AtomicWriteError("@atomic_write must decorate a bound method (expects 'self' as first arg)")
+ owner = args[0]
+ backend = _resolve_backend(owner)
+
+ agent_id = _resolve_agent_id(fn, args, kwargs, agent_id_param)
+
+ # FIX: Skip internal locking for sync methods to avoid deadlock
+ # when called from async contexts that already hold AgentLocks
+ lock = None
+ if use_lock and agent_id:
+ # Check if we're being called from an async context
+ try:
+ asyncio.get_running_loop()
+ # We're in an async context - assume external AgentLocks are used
+ # Skip internal threading lock to avoid deadlock
+ lock = None
+ except RuntimeError:
+ # No running loop - safe to use threading locks
+ tlocks: ThreadWriteLocks = getattr(owner, "_atomic_write_thread_locks", None) # type: ignore[assignment]
+ if tlocks is None:
+ tlocks = ThreadWriteLocks()
+ setattr(owner, "_atomic_write_thread_locks", tlocks)
+ lock = tlocks.get(str(agent_id))
+
+ def _do():
+ backend.begin()
+ try:
+ result = fn(*args, **kwargs)
+ backend.commit()
+ return result
+ except Exception:
+ try:
+ backend.rollback()
+ finally:
+ pass
+ raise
+
+ if lock is None:
+ return _do()
+ with lock:
+ return _do()
+
+ return sync_wrapper
+
+ return decorator
+
+
+
+
diff --git a/src/mcpstore/core/registry/cache_manager.py b/src/mcpstore/core/registry/cache_manager.py
new file mode 100644
index 00000000..77d733a0
--- /dev/null
+++ b/src/mcpstore/core/registry/cache_manager.py
@@ -0,0 +1,226 @@
+import copy
+import logging
+from datetime import datetime
+from typing import Dict, Any
+
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceCacheManager:
+ """
+ Service cache manager - provides advanced cache operations
+ """
+
+ def __init__(self, registry, lifecycle_manager):
+ self.registry = registry
+ self.lifecycle_manager = lifecycle_manager
+
+ # === Intelligent cache operations ===
+
+ async def smart_add_service(self, agent_id: str, service_name: str, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Smart add service: automatically handles connection, state management, cache updates
+
+ Returns:
+ {
+ "success": True,
+ "state": "healthy",
+ "tools_added": 5,
+ "message": "Service added successfully"
+ }
+ """
+ try:
+ # 1. Initialize to lifecycle manager
+ await self.lifecycle_manager.initialize_service(agent_id, service_name, service_config)
+
+ # 2. Immediately add to cache (initializing state)
+ self.registry.add_service(
+ agent_id=agent_id,
+ name=service_name,
+ session=None,
+ tools=[],
+ service_config=service_config,
+ state=ServiceConnectionState.INITIALIZING
+ )
+
+ return {
+ "success": True,
+ "state": "initializing",
+ "tools_added": 0,
+ "message": "Service added to cache, connecting in background"
+ }
+
+ except Exception as e:
+ # 5. Exception handling, record error status
+ self.registry.add_failed_service(agent_id, service_name, service_config, str(e))
+ return {
+ "success": False,
+ "state": "disconnected",
+ "tools_added": 0,
+ "message": f"Service addition failed: {str(e)}"
+ }
+
+ def sync_from_client_manager(self, client_manager):
+ """
+ Single data source architecture: ClientManager no longer manages shard files
+
+ Under the new architecture, cache is not synchronized from ClientManager,
+ but from mcp.json through UnifiedMCPSyncManager
+ """
+ try:
+ # Check if cache has been initialized
+ cache_initialized = getattr(self.registry, 'cache_initialized', False)
+
+ if not cache_initialized:
+ # Single data source mode: initialize empty cache, wait for synchronization from mcp.json
+ logger.info(" [CACHE_INIT] Single data source mode: initializing empty cache, waiting for synchronization from mcp.json")
+
+ # Initialize empty cache
+ # agent_clients removed - now derived from service_client mappings in pyvk
+ # client_configs removed - now in pyvk only
+ logger.info(" [CACHE_INIT] Empty cache initialization completed")
+
+ # Mark cache as initialized
+ self.registry.cache_initialized = True
+
+ else:
+ # Runtime: single data source mode does not need ClientManager synchronization
+ logger.info(" [CACHE_SYNC] Single data source mode: skipping ClientManager synchronization at runtime")
+ logger.info(" [CACHE_SYNC] Cache data is synchronized from mcp.json by UnifiedMCPSyncManager")
+
+ # Update synchronization time (record operation)
+ from datetime import datetime
+ self.registry.cache_sync_status["client_manager"] = datetime.now()
+ self.registry.cache_sync_status["sync_mode"] = "single_source_mode"
+
+ logger.info(" [CACHE_INIT] ClientManager synchronization completed (single data source mode)")
+
+ except Exception as e:
+ logger.error(f"Failed to sync cache from ClientManager: {e}")
+ raise
+
+ def sync_to_client_manager(self, client_manager):
+ """
+ Single data source architecture: no longer synchronize to ClientManager
+
+ Under the new architecture, cache data is only synchronized to mcp.json,
+ shard files are no longer maintained
+ """
+ try:
+ # Single data source mode: skip ClientManager synchronization
+ logger.info(" [CACHE_SYNC] Single data source mode: skipping ClientManager synchronization, only maintaining mcp.json")
+
+ # Update synchronization time (record skipped operation)
+ from datetime import datetime
+ self.registry.cache_sync_status["to_client_manager"] = datetime.now()
+ self.registry.cache_sync_status["sync_skipped"] = "single_source_mode"
+
+ except Exception as e:
+ logger.error(f"Failed to update sync status: {e}")
+ raise
+
+
+class CacheTransactionManager:
+ """Cache transaction manager - supports rollback"""
+
+ def __init__(self, registry):
+ self.registry = registry
+ self.transaction_stack = []
+ self.max_transactions = 10 # Maximum number of transactions
+ self.transaction_timeout = 3600 # Transaction timeout time (seconds)
+
+ async def begin_transaction(self, transaction_id: str):
+ """Begin cache transaction
+
+ Note: tool_cache, service_to_client, client_configs removed - now stored in pyvk only.
+ Transaction snapshots only cover in-memory runtime data.
+ """
+ # Create current state snapshot (only in-memory runtime data)
+ snapshot = {
+ "transaction_id": transaction_id,
+ "timestamp": datetime.now(),
+ # agent_clients removed - now derived from service_client mappings in pyvk
+ # client_configs removed - now in pyvk only
+ # service_to_client removed - now in pyvk only
+ "service_states": copy.deepcopy(self.registry.service_states),
+ "service_metadata": copy.deepcopy(self.registry.service_metadata),
+ "sessions": copy.deepcopy(self.registry.sessions)
+ }
+
+ self.transaction_stack.append(snapshot)
+
+ # Clean up expired and excessive transactions
+ self._cleanup_transactions()
+
+ logger.debug(f"Started cache transaction: {transaction_id}")
+
+ async def commit_transaction(self, transaction_id: str):
+ """Commit cache transaction"""
+ # Remove corresponding snapshot
+ self.transaction_stack = [
+ snap for snap in self.transaction_stack
+ if snap["transaction_id"] != transaction_id
+ ]
+ logger.debug(f"Committed cache transaction: {transaction_id}")
+
+ async def rollback_transaction(self, transaction_id: str):
+ """Rollback cache transaction"""
+ # Find corresponding snapshot
+ snapshot = None
+ for snap in self.transaction_stack:
+ if snap["transaction_id"] == transaction_id:
+ snapshot = snap
+ break
+
+ if not snapshot:
+ logger.error(f"Transaction snapshot not found: {transaction_id}")
+ return False
+
+ try:
+ # Restore cache state (only in-memory runtime data)
+ # agent_clients removed - now derived from service_client mappings in pyvk
+ # client_configs removed - now in pyvk only
+ # service_to_client removed - now in pyvk only
+ self.registry.service_states = snapshot["service_states"]
+ self.registry.service_metadata = snapshot["service_metadata"]
+ self.registry.sessions = snapshot["sessions"]
+
+ # Remove snapshot
+ self.transaction_stack = [
+ snap for snap in self.transaction_stack
+ if snap["transaction_id"] != transaction_id
+ ]
+
+ logger.info(f"Rolled back cache transaction: {transaction_id}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to rollback transaction {transaction_id}: {e}")
+ return False
+
+ def _cleanup_transactions(self):
+ """Clean up expired and excessive transactions"""
+ current_time = datetime.now()
+
+ # Clean up expired transactions
+ self.transaction_stack = [
+ snap for snap in self.transaction_stack
+ if (current_time - snap["timestamp"]).total_seconds() < self.transaction_timeout
+ ]
+
+ # Limit transaction count (keep latest)
+ if len(self.transaction_stack) > self.max_transactions:
+ self.transaction_stack = self.transaction_stack[-self.max_transactions:]
+ logger.warning(f"Transaction stack exceeded limit, kept latest {self.max_transactions} transactions")
+
+ def get_transaction_count(self) -> int:
+ """Get current transaction count"""
+ return len(self.transaction_stack)
+
+ def clear_all_transactions(self):
+ """Clear all transactions (use with caution)"""
+ count = len(self.transaction_stack)
+ self.transaction_stack.clear()
+ logger.warning(f"Cleared all {count} transactions from stack")
diff --git a/src/mcpstore/core/registry/config_sync_manager.py b/src/mcpstore/core/registry/config_sync_manager.py
new file mode 100644
index 00000000..7f353383
--- /dev/null
+++ b/src/mcpstore/core/registry/config_sync_manager.py
@@ -0,0 +1,343 @@
+"""
+配置同步管理器
+
+负责在不同工作模式下管理配置的同步:
+- JSON 到缓存的同步(本地模式、混合模式初始化时)
+- 缓存到 JSON 的同步(共享模式导出时)
+- 配置变更的增量同步
+
+支持双向同步和冲突检测。
+"""
+
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, Dict, Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from key_value.aio.protocols import AsyncKeyValue
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigSyncManager:
+ """
+ 配置同步管理器
+
+ 负责在 JSON 文件和缓存之间同步配置数据。
+ 支持完整同步和增量同步。
+ """
+
+ def __init__(
+ self,
+ kv_store: 'AsyncKeyValue',
+ namespace: Optional[str] = None
+ ):
+ """
+ 初始化配置同步管理器
+
+ Args:
+ kv_store: py-key-value 存储实例
+ namespace: 命名空间前缀(用于多实例隔离)
+ """
+ self.kv_store = kv_store
+ self.namespace = namespace
+ self._last_sync_time: Optional[datetime] = None
+
+ async def sync_json_to_cache(
+ self,
+ json_path: str,
+ overwrite: bool = True
+ ) -> Dict[str, Any]:
+ """
+ 从 JSON 文件同步配置到缓存
+
+ Args:
+ json_path: JSON 配置文件路径
+ overwrite: 是否覆盖缓存中的现有配置
+
+ Returns:
+ 同步后的配置字典
+
+ Raises:
+ FileNotFoundError: 如果 JSON 文件不存在
+ json.JSONDecodeError: 如果 JSON 格式无效
+
+ 工作流程:
+ 1. 从 JSON 文件加载配置
+ 2. 如果 overwrite=False,合并缓存中的现有配置
+ 3. 将配置写入缓存
+ 4. 更新同步时间戳
+ """
+ logger.info(f"Syncing configuration from JSON to cache: {json_path}")
+
+ # 1. 加载 JSON 配置
+ json_config = self._load_json_file(json_path)
+
+ # 2. 如果不覆盖,合并现有配置
+ if not overwrite:
+ existing_config = await self._load_cache_config()
+ if existing_config:
+ logger.debug("Merging with existing cache configuration")
+ json_config = self._merge_configs(existing_config, json_config)
+
+ # 3. 写入缓存
+ await self._save_cache_config(json_config)
+
+ # 4. 更新同步时间
+ self._last_sync_time = datetime.now()
+ await self._save_sync_metadata({
+ "last_sync_time": self._last_sync_time.isoformat(),
+ "sync_direction": "json_to_cache",
+ "source_file": json_path
+ })
+
+ logger.info(f"Successfully synced {len(json_config)} items from JSON to cache")
+ return json_config
+
+ async def sync_cache_to_json(
+ self,
+ output_path: str,
+ overwrite: bool = True,
+ pretty: bool = True
+ ) -> Dict[str, Any]:
+ """
+ 从缓存同步配置到 JSON 文件
+
+ Args:
+ output_path: 输出 JSON 文件路径
+ overwrite: 是否覆盖现有 JSON 文件
+ pretty: 是否格式化输出(缩进)
+
+ Returns:
+ 同步后的配置字典
+
+ Raises:
+ FileExistsError: 如果文件已存在且 overwrite=False
+ RuntimeError: 如果写入失败
+
+ 工作流程:
+ 1. 从缓存加载配置
+ 2. 如果 overwrite=False 且文件存在,合并现有 JSON
+ 3. 将配置写入 JSON 文件
+ 4. 更新同步时间戳
+ """
+ logger.info(f"Syncing configuration from cache to JSON: {output_path}")
+
+ # 1. 加载缓存配置
+ cache_config = await self._load_cache_config()
+
+ if not cache_config:
+ logger.warning("No configuration found in cache to sync")
+ cache_config = {}
+
+ # 2. 如果不覆盖且文件存在,合并现有 JSON
+ output_file = Path(output_path)
+ if not overwrite and output_file.exists():
+ logger.debug("Merging with existing JSON file")
+ existing_json = self._load_json_file(output_path)
+ cache_config = self._merge_configs(existing_json, cache_config)
+
+ # 3. 写入 JSON 文件
+ self._save_json_file(output_path, cache_config, pretty)
+
+ # 4. 更新同步时间
+ self._last_sync_time = datetime.now()
+ await self._save_sync_metadata({
+ "last_sync_time": self._last_sync_time.isoformat(),
+ "sync_direction": "cache_to_json",
+ "output_file": output_path
+ })
+
+ logger.info(f"Successfully synced {len(cache_config)} items from cache to JSON")
+ return cache_config
+
+ async def get_sync_status(self) -> Dict[str, Any]:
+ """
+ 获取同步状态信息
+
+ Returns:
+ 同步状态字典,包含最后同步时间、方向等信息
+ """
+ metadata = await self._load_sync_metadata()
+
+ return {
+ "last_sync_time": metadata.get("last_sync_time"),
+ "sync_direction": metadata.get("sync_direction"),
+ "source_file": metadata.get("source_file"),
+ "output_file": metadata.get("output_file"),
+ "namespace": self.namespace
+ }
+
+ async def clear_sync_metadata(self) -> None:
+ """
+ 清除同步元数据
+
+ 主要用于测试和重置场景
+ """
+ collection = self._get_metadata_collection()
+
+ try:
+ await self.kv_store.delete("sync_metadata", collection=collection)
+ self._last_sync_time = None
+ logger.debug("Sync metadata cleared")
+
+ except Exception as e:
+ logger.warning(f"Failed to clear sync metadata: {e}")
+
+ # ========== 私有辅助方法 ==========
+
+ def _load_json_file(self, json_path: str) -> Dict[str, Any]:
+ """从 JSON 文件加载配置"""
+ path = Path(json_path)
+
+ if not path.exists():
+ raise FileNotFoundError(f"Configuration file not found: {json_path}")
+
+ try:
+ with open(path, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+
+ if not isinstance(config, dict):
+ logger.warning(f"JSON file does not contain a dictionary: {json_path}")
+ return {}
+
+ return config
+
+ except json.JSONDecodeError as e:
+ logger.error(f"Invalid JSON format in {json_path}: {e}")
+ raise
+
+ except Exception as e:
+ logger.error(f"Failed to load JSON file {json_path}: {e}")
+ raise
+
+ def _save_json_file(
+ self,
+ json_path: str,
+ config: Dict[str, Any],
+ pretty: bool = True
+ ) -> None:
+ """保存配置到 JSON 文件"""
+ path = Path(json_path)
+
+ try:
+ # 确保目录存在
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ # 写入文件
+ with open(path, 'w', encoding='utf-8') as f:
+ if pretty:
+ json.dump(config, f, indent=2, ensure_ascii=False)
+ else:
+ json.dump(config, f, ensure_ascii=False)
+
+ logger.debug(f"Configuration saved to {json_path}")
+
+ except Exception as e:
+ logger.error(f"Failed to save JSON file {json_path}: {e}")
+ raise RuntimeError(f"Failed to save configuration: {e}")
+
+ async def _load_cache_config(self) -> Dict[str, Any]:
+ """从缓存加载配置"""
+ collection = self._get_config_collection()
+
+ try:
+ config = await self.kv_store.get("mcp_config", collection=collection)
+
+ if config is None:
+ return {}
+
+ if not isinstance(config, dict):
+ logger.warning(f"Invalid configuration type in cache: {type(config)}")
+ return {}
+
+ return config
+
+ except Exception as e:
+ logger.error(f"Failed to load configuration from cache: {e}")
+ return {}
+
+ async def _save_cache_config(self, config: Dict[str, Any]) -> None:
+ """保存配置到缓存"""
+ collection = self._get_config_collection()
+
+ try:
+ await self.kv_store.put("mcp_config", config, collection=collection)
+ logger.debug(f"Configuration saved to cache (collection={collection})")
+
+ except Exception as e:
+ logger.error(f"Failed to save configuration to cache: {e}")
+ raise
+
+ async def _load_sync_metadata(self) -> Dict[str, Any]:
+ """加载同步元数据"""
+ collection = self._get_metadata_collection()
+
+ try:
+ metadata = await self.kv_store.get("sync_metadata", collection=collection)
+
+ if metadata is None:
+ return {}
+
+ if not isinstance(metadata, dict):
+ return {}
+
+ return metadata
+
+ except Exception as e:
+ logger.debug(f"Failed to load sync metadata: {e}")
+ return {}
+
+ async def _save_sync_metadata(self, metadata: Dict[str, Any]) -> None:
+ """保存同步元数据"""
+ collection = self._get_metadata_collection()
+
+ try:
+ await self.kv_store.put("sync_metadata", metadata, collection=collection)
+
+ except Exception as e:
+ logger.warning(f"Failed to save sync metadata: {e}")
+
+ def _merge_configs(
+ self,
+ base: Dict[str, Any],
+ update: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """
+ 合并两个配置字典
+
+ Args:
+ base: 基础配置(优先级低)
+ update: 更新配置(优先级高)
+
+ Returns:
+ 合并后的配置
+
+ Note:
+ 使用深度合并策略,update 中的值会覆盖 base 中的值
+ """
+ result = base.copy()
+
+ for key, value in update.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ # 递归合并嵌套字典
+ result[key] = self._merge_configs(result[key], value)
+ else:
+ # 直接覆盖
+ result[key] = value
+
+ return result
+
+ def _get_config_collection(self) -> str:
+ """获取配置存储的 Collection 名称"""
+ if self.namespace:
+ return f"{self.namespace}:config:global"
+ return "config:global"
+
+ def _get_metadata_collection(self) -> str:
+ """获取元数据存储的 Collection 名称"""
+ if self.namespace:
+ return f"{self.namespace}:config:metadata"
+ return "config:metadata"
diff --git a/src/mcpstore/core/registry/core_registry/__init__.py b/src/mcpstore/core/registry/core_registry/__init__.py
new file mode 100644
index 00000000..4cdadd05
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/__init__.py
@@ -0,0 +1,112 @@
+"""
+Core Registry Module - 拆分重构后的服务注册管理模块
+
+本模块将原来的巨大 core_registry.py 文件拆分为多个专门的管理器,
+每个管理器负责特定的职责,同时保持完全的向后兼容性。
+
+模块结构:
+- main_registry: ServiceRegistry 主类(门面模式)
+- base: 基础类和接口定义
+- service_manager: 服务生命周期管理
+- tool_manager: 工具信息处理和管理
+- state_manager: 状态同步和元数据管理
+- session_manager: 会话管理
+- cache_manager: 缓存层管理
+- persistence: JSON 持久化相关
+- utils: 工具函数和辅助方法
+"""
+
+# 导出各个管理器类,供高级用户使用
+from .base import (
+ BaseManager,
+ ServiceManagerInterface,
+ ToolManagerInterface,
+ StateManagerInterface,
+ SessionManagerInterface,
+ PersistenceManagerInterface,
+ CacheManagerInterface,
+ ManagerFactory,
+ ManagerCoordinator
+)
+from .cache_manager import CacheManager
+# 重新导出保持兼容性
+from .main_registry import ServiceRegistry
+from .mapping_manager import MappingManager
+from .persistence import PersistenceManager
+from .service_manager import ServiceManager
+from .session_manager import SessionManager
+from .state_manager import StateManager
+from .tool_manager import ToolManager
+# 导出工具类
+from .utils import (
+ JSONSchemaUtils,
+ ConfigUtils,
+ ServiceUtils,
+ DataUtils,
+ ValidationUtils,
+ extract_description_from_schema,
+ extract_type_from_schema
+)
+
+__all__ = [
+ # 主要导出(向后兼容)
+ 'ServiceRegistry',
+
+ # 管理器类
+ 'SessionManager',
+ 'StateManager',
+ 'ToolManager',
+ 'CacheManager',
+ 'PersistenceManager',
+ 'ServiceManager',
+ 'MappingManager',
+
+ # 基础接口
+ 'BaseManager',
+ 'ServiceManagerInterface',
+ 'ToolManagerInterface',
+ 'StateManagerInterface',
+ 'SessionManagerInterface',
+ 'PersistenceManagerInterface',
+ 'CacheManagerInterface',
+ 'ManagerFactory',
+ 'ManagerCoordinator',
+
+ # 工具类
+ 'JSONSchemaUtils',
+ 'ConfigUtils',
+ 'ServiceUtils',
+ 'DataUtils',
+ 'ValidationUtils',
+ 'extract_description_from_schema',
+ 'extract_type_from_schema'
+]
+
+# 模块版本和状态
+__version__ = "2.0.0"
+__status__ = "重构完成 - 已完成所有功能"
+
+# 模块信息
+__author__ = "Core Registry Refactoring Team"
+__description__ = "拆分重构后的服务注册管理模块"
+__all_managers__ = [
+ 'SessionManager',
+ 'StateManager',
+ 'ToolManager',
+ 'CacheManager',
+ 'PersistenceManager',
+ 'ServiceManager',
+ 'MappingManager'
+]
+
+def get_module_info():
+ """获取模块信息"""
+ return {
+ "version": __version__,
+ "status": __status__,
+ "completed_managers": len(__all_managers__) + 1, # 包括主类ServiceRegistry
+ "total_managers": 8,
+ "available_managers": __all_managers__ + ["ServiceRegistry"],
+ "compatibility": "complete", # 完全向后兼容
+ "next_step": "重构已完成,可以安全删除原始文件"
+ }
\ No newline at end of file
diff --git a/src/mcpstore/core/registry/core_registry/base.py b/src/mcpstore/core/registry/core_registry/base.py
new file mode 100644
index 00000000..7411feed
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/base.py
@@ -0,0 +1,332 @@
+"""
+Core Registry Base Classes - 基础类和接口定义
+
+定义所有管理器的基础接口和抽象类,确保模块间的解耦和一致性。
+"""
+
+import logging
+from abc import ABC, abstractmethod
+from typing import Dict, Any, Optional, List
+
+logger = logging.getLogger(__name__)
+
+
+class BaseManager(ABC):
+ """基础管理器抽象类"""
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ self._cache_layer = cache_layer
+ self._naming = naming_service
+ self._namespace = namespace
+ self._logger = logging.getLogger(self.__class__.__name__)
+
+ @abstractmethod
+ def initialize(self) -> None:
+ """初始化管理器"""
+ pass
+
+ @abstractmethod
+ def cleanup(self) -> None:
+ """清理管理器资源"""
+ pass
+
+
+class ServiceManagerInterface(BaseManager):
+ """服务管理器接口"""
+
+ @abstractmethod
+ def add_service(self, agent_id: str, name: str, **kwargs) -> bool:
+ """添加服务"""
+ pass
+
+ @abstractmethod
+ def add_service_async(self, agent_id: str, name: str, **kwargs) -> bool:
+ """异步添加服务"""
+ pass
+
+ @abstractmethod
+ def remove_service(self, agent_id: str, name: str) -> Optional[Any]:
+ """移除服务"""
+ pass
+
+ @abstractmethod
+ def remove_service_async(self, agent_id: str, name: str) -> Optional[Any]:
+ """异步移除服务"""
+ pass
+
+ @abstractmethod
+ def replace_service_tools(self, agent_id: str, service_name: str, **kwargs) -> Dict[str, Any]:
+ """替换服务工具"""
+ pass
+
+ @abstractmethod
+ def replace_service_tools_async(self, agent_id: str, service_name: str, **kwargs) -> Dict[str, Any]:
+ """异步替换服务工具"""
+ pass
+
+ @abstractmethod
+ def add_failed_service(self, agent_id: str, name: str, **kwargs) -> bool:
+ """添加失败服务"""
+ pass
+
+ @abstractmethod
+ def get_services_for_agent(self, agent_id: str) -> List[str]:
+ """获取代理的所有服务"""
+ pass
+
+ @abstractmethod
+ def get_service_details(self, agent_id: str, name: str) -> Dict[str, Any]:
+ """获取服务详情"""
+ pass
+
+ @abstractmethod
+ def get_service_info(self, agent_id: str, service_name: str) -> Optional['ServiceInfo']:
+ """获取服务信息"""
+ pass
+
+ @abstractmethod
+ def get_service_config(self, agent_id: str, name: str) -> Optional[Dict[str, Any]]:
+ """获取服务配置"""
+ pass
+
+ @abstractmethod
+ def clear(self, agent_id: str):
+ """清除代理的所有服务"""
+ pass
+
+ @abstractmethod
+ def clear_async(self, agent_id: str) -> None:
+ """异步清除代理的所有服务"""
+ pass
+
+
+class ToolManagerInterface(BaseManager):
+ """工具管理器接口"""
+
+ @abstractmethod
+ def get_all_tools(self, agent_id: str) -> List[Dict[str, Any]]:
+ """获取所有工具"""
+ pass
+
+ @abstractmethod
+ def get_all_tools_dict_async(self, agent_id: str) -> Dict[str, Dict[str, Any]]:
+ """异步获取所有工具字典"""
+ pass
+
+ @abstractmethod
+ def list_tools(self, agent_id: str) -> List['ToolInfo']:
+ """列出工具"""
+ pass
+
+ @abstractmethod
+ def get_all_tool_info(self, agent_id: str) -> List[Dict[str, Any]]:
+ """获取所有工具信息"""
+ pass
+
+ @abstractmethod
+ def get_tools_for_service(self, agent_id: str, service_name: str) -> List[str]:
+ """获取服务的工具列表"""
+ pass
+
+ @abstractmethod
+ def get_tools_for_service_async(self, agent_id: str, service_name: str) -> List[str]:
+ """异步获取服务的工具列表"""
+ pass
+
+ @abstractmethod
+ def get_tool_info(self, agent_id: str, tool_name: str) -> Dict[str, Any]:
+ """获取工具信息"""
+ pass
+
+ @abstractmethod
+ def get_session_for_tool(self, agent_id: str, tool_name: str) -> Optional[Any]:
+ """获取工具的会话"""
+ pass
+
+
+class StateManagerInterface(BaseManager):
+ """状态管理器接口"""
+
+ @abstractmethod
+ def set_service_state(self, agent_id: str, service_name: str, state: Optional['ServiceConnectionState']):
+ """设置服务状态"""
+ pass
+
+ @abstractmethod
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata: Optional['ServiceStateMetadata']):
+ """设置服务元数据"""
+ pass
+
+ @abstractmethod
+ def get_all_service_states(self, agent_id: str) -> Dict[str, 'ServiceConnectionState']:
+ """获取所有服务状态"""
+ pass
+
+ @abstractmethod
+ def get_all_service_states_async(self, agent_id: str) -> Dict[str, 'ServiceConnectionState']:
+ """异步获取所有服务状态"""
+ pass
+
+ @abstractmethod
+ def get_connected_services(self, agent_id: str) -> List[Dict[str, Any]]:
+ """获取已连接的服务"""
+ pass
+
+
+class SessionManagerInterface(BaseManager):
+ """会话管理器接口"""
+
+ @abstractmethod
+ def get_session(self, agent_id: str, name: str) -> Optional[Any]:
+ """获取会话"""
+ pass
+
+ @abstractmethod
+ def set_session(self, agent_id: str, service_name: str, session: Any) -> None:
+ """设置会话"""
+ pass
+
+ @abstractmethod
+ def clear_session(self, agent_id: str, service_name: str):
+ """清除特定服务的会话"""
+ pass
+
+ @abstractmethod
+ def clear_all_sessions(self, agent_id: str):
+ """清除代理的所有会话"""
+ pass
+
+
+class PersistenceManagerInterface(BaseManager):
+ """持久化管理器接口"""
+
+ @abstractmethod
+ def load_services_from_json(self) -> Dict[str, Any]:
+ """从JSON加载服务配置"""
+ pass
+
+ @abstractmethod
+ def load_services_from_json_async(self) -> Dict[str, Any]:
+ """异步从JSON加载服务配置"""
+ pass
+
+ @abstractmethod
+ def extract_standard_mcp_config(self, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """提取标准MCP配置"""
+ pass
+
+
+class CacheManagerInterface(BaseManager):
+ """缓存管理器接口"""
+
+ @abstractmethod
+ def configure_cache_backend(self, cache_config: Dict[str, Any]) -> None:
+ """配置缓存后端"""
+ pass
+
+ @abstractmethod
+ def sync_to_storage(self, operation_name: str = "缓存同步"):
+ """同步到存储"""
+ pass
+
+ @abstractmethod
+ def ensure_sync_helper(self):
+ """确保同步助手存在"""
+ pass
+
+
+class ManagerFactory:
+ """管理器工厂类"""
+
+ @staticmethod
+ def create_service_manager(cache_layer, naming_service, **kwargs) -> ServiceManagerInterface:
+ """创建服务管理器实例"""
+ from .service_manager import ServiceManager
+ return ServiceManager(cache_layer, naming_service, **kwargs)
+
+ @staticmethod
+ def create_tool_manager(cache_layer, naming_service, **kwargs) -> ToolManagerInterface:
+ """创建工具管理器实例"""
+ from .tool_manager import ToolManager
+ return ToolManager(cache_layer, naming_service, **kwargs)
+
+ @staticmethod
+ def create_state_manager(cache_layer, naming_service, **kwargs) -> StateManagerInterface:
+ """创建状态管理器实例"""
+ from .state_manager import StateManager
+ return StateManager(cache_layer, naming_service, **kwargs)
+
+ @staticmethod
+ def create_session_manager(cache_layer, naming_service, **kwargs) -> SessionManagerInterface:
+ """创建会话管理器实例"""
+ from .session_manager import SessionManager
+ return SessionManager(cache_layer, naming_service, **kwargs)
+
+ @staticmethod
+ def create_persistence_manager(cache_layer, naming_service, **kwargs) -> PersistenceManagerInterface:
+ """创建持久化管理器实例"""
+ from .persistence import PersistenceManager
+ return PersistenceManager(cache_layer, naming_service, **kwargs)
+
+ @staticmethod
+ def create_cache_manager(cache_layer, naming_service, **kwargs) -> CacheManagerInterface:
+ """创建缓存管理器实例"""
+ from .cache_manager import CacheManager
+ return CacheManager(cache_layer, naming_service, **kwargs)
+
+
+class ManagerCoordinator:
+ """管理器协调器 - 处理管理器间的依赖和协作"""
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ self._cache_layer = cache_layer
+ self._naming = naming_service
+ self._namespace = namespace
+ self._managers = {}
+ self._factory = ManagerFactory()
+
+ def initialize_managers(self):
+ """初始化所有管理器"""
+ base_kwargs = {"namespace": self._namespace}
+
+ self._managers = {
+ 'service': self._factory.create_service_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ ),
+ 'tool': self._factory.create_tool_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ ),
+ 'state': self._factory.create_state_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ ),
+ 'session': self._factory.create_session_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ ),
+ 'persistence': self._factory.create_persistence_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ ),
+ 'cache': self._factory.create_cache_manager(
+ self._cache_layer, self._naming, **base_kwargs
+ )
+ }
+
+ # 初始化所有管理器
+ for name, manager in self._managers.items():
+ manager.initialize()
+ logger.info(f"Initializing manager: {name}")
+
+ def get_manager(self, manager_type: str):
+ """获取指定类型的管理器"""
+ if manager_type not in self._managers:
+ raise ValueError(f"Unknown manager type: {manager_type}")
+ return self._managers[manager_type]
+
+ def cleanup_all_managers(self):
+ """清理所有管理器"""
+ for name, manager in self._managers.items():
+ try:
+ manager.cleanup()
+ logger.info(f"Cleaning up manager: {name}")
+ except Exception as e:
+ logger.error(f"Error cleaning up manager {name}: {e}")
+ self._managers.clear()
diff --git a/src/mcpstore/core/registry/core_registry/cache_manager.py b/src/mcpstore/core/registry/core_registry/cache_manager.py
new file mode 100644
index 00000000..9f13f7f1
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/cache_manager.py
@@ -0,0 +1,346 @@
+"""
+Cache Manager - 缓存管理模块
+
+负责缓存层的配置和同步管理,包括:
+1. 缓存后端的配置和管理
+2. 同步/异步操作转换
+3. 缓存同步机制
+4. 异常处理和重试逻辑
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any, Optional, Callable, List
+
+from .base import CacheManagerInterface
+from .errors import raise_legacy_error
+from ...bridge import get_async_bridge
+
+logger = logging.getLogger(__name__)
+
+
+class CacheManager(CacheManagerInterface):
+ """
+ 缓存管理器实现
+
+ 职责:
+ - 管理缓存后端配置
+ - 处理同步到异步的转换
+ - 提供缓存同步机制
+ - 异常处理和重试
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 缓存后端配置
+ self._cache_backend = None
+
+ self._bridge = get_async_bridge()
+
+ # 缓存同步状态
+ self._sync_status = {}
+
+ # 重试配置
+ self._retry_config = {
+ "max_retries": 3,
+ "retry_delay": 1.0,
+ "backoff_factor": 2.0
+ }
+
+ self._logger.info(f"Initializing CacheManager, namespace: {namespace}")
+
+ def _legacy(self, method: str) -> None:
+ raise_legacy_error(
+ f"core_registry.CacheManager.{method}",
+ "Use CacheLayerManager and domain shells for cache operations.",
+ )
+
+ def initialize(self) -> None:
+ """初始化缓存管理器"""
+ self._logger.info("CacheManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理缓存管理器资源"""
+ try:
+ # 清理缓存后端
+ if self._cache_backend:
+ try:
+ if hasattr(self._cache_backend, 'close'):
+ self._cache_backend.close()
+ except Exception as e:
+ self._logger.warning(f"Error closing cache backend: {e}")
+
+ # 清理同步助手
+ self._sync_helper = None
+
+ # 清理同步状态
+ self._sync_status.clear()
+
+ self._logger.info("CacheManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"CacheManager cleanup error: {e}")
+ raise
+
+ def configure_cache_backend(self, cache_config: Dict[str, Any]) -> None:
+ """
+ 配置缓存后端
+
+ Args:
+ cache_config: 缓存配置字典
+ """
+ self._legacy("configure_cache_backend")
+
+ def _create_cache_backend(self, cache_config: Dict[str, Any]):
+ """
+ 创建缓存后端实例
+
+ Args:
+ cache_config: 缓存配置
+
+ Returns:
+ 缓存后端实例
+ """
+ self._legacy("_create_cache_backend")
+
+ def _create_memory_backend(self, config: Dict[str, Any]):
+ """创建内存缓存后端"""
+ self._legacy("_create_memory_backend")
+
+ def _create_redis_backend(self, config: Dict[str, Any]):
+ """创建Redis缓存后端"""
+ self._legacy("_create_redis_backend")
+
+ def _create_file_backend(self, config: Dict[str, Any]):
+ """创建文件缓存后端"""
+ self._legacy("_create_file_backend")
+
+ def cleanup_cache_backend(self):
+ """清理现有的缓存后端"""
+ self._legacy("cleanup_cache_backend")
+
+ def ensure_sync_helper(self):
+ """
+ 向后兼容的同步助手接口,实际返回异步桥实例。
+ """
+ self._legacy("ensure_sync_helper")
+
+ def sync_to_storage(self, operation_name: str = "缓存同步") -> Any:
+ """
+ 同步到存储(同步方法调用异步操作)
+
+ Args:
+ operation_name: 操作名称,用于日志记录
+
+ Returns:
+ 异步操作的结果
+ """
+ self._legacy("sync_to_storage")
+
+ def async_to_sync(self, async_coro, operation_name: str = "异步转同步") -> Any:
+ """
+ 将异步协程转换为同步调用
+
+ Args:
+ async_coro: 异步协程
+ operation_name: 操作名称
+
+ Returns:
+ 异步协程的结果
+ """
+ self._legacy("async_to_sync")
+
+ def retry_operation(self, operation: Callable, *args, **kwargs) -> Any:
+ """
+ 带重试的操作执行
+
+ Args:
+ operation: 要执行的操作函数
+ *args: 位置参数
+ **kwargs: 关键字参数
+
+ Returns:
+ 操作结果
+ """
+ self._legacy("retry_operation")
+
+ def get_sync_status(self, operation_name: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 获取同步状态信息
+
+ Args:
+ operation_name: 可选的操作名称,如果为None则返回所有状态
+
+ Returns:
+ 同步状态信息
+ """
+ self._legacy("get_sync_status")
+
+ def clear_sync_status(self, operation_name: Optional[str] = None):
+ """
+ 清理同步状态
+
+ Args:
+ operation_name: 可选的操作名称,如果为None则清理所有状态
+ """
+ self._legacy("clear_sync_status")
+
+ def get_backend_info(self) -> Dict[str, Any]:
+ """
+ 获取缓存后端信息
+
+ Returns:
+ 后端信息字典
+ """
+ self._legacy("get_backend_info")
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取缓存管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ self._legacy("get_stats")
+
+
+class AsyncSyncHelper:
+ """异步同步助手,用于在同步环境中运行异步操作"""
+
+ def __init__(self):
+ self._loop = None
+
+ def run_sync(self, coro):
+ """
+ 在同步环境中运行异步协程
+
+ Args:
+ coro: 异步协程
+
+ Returns:
+ 异步操作的结果
+ """
+ try:
+ # 尝试获取当前事件循环
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # 如果事件循环正在运行,我们需要在新线程中运行
+ import concurrent.futures
+ import threading
+
+ def run_in_thread():
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ try:
+ return new_loop.run_until_complete(coro)
+ finally:
+ new_loop.close()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(run_in_thread)
+ return future.result()
+ else:
+ # 如果事件循环没有运行,直接运行
+ return loop.run_until_complete(coro)
+ except RuntimeError:
+ # 没有事件循环,创建一个新的
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+# 简单内存缓存后端(作为后备方案)
+class SimpleMemoryBackend:
+ """简单的内存缓存后端实现,作为 kv_store_factory 失败时的后备方案"""
+
+ def __init__(self, config):
+ self.type = "memory"
+ self.config = config
+ self._data = {}
+ self._stats = {
+ "hits": 0,
+ "misses": 0,
+ "sets": 0,
+ "deletes": 0
+ }
+ self._max_size = config.get("max_size", 10000)
+ self._logger = logging.getLogger(self.__class__.__name__)
+
+ def get(self, key: str) -> Optional[str]:
+ """获取缓存值"""
+ if key in self._data:
+ self._stats["hits"] += 1
+ return self._data[key]
+ else:
+ self._stats["misses"] += 1
+ return None
+
+ def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
+ """设置缓存值"""
+ try:
+ # 如果超过最大大小,执行简单的LRU清理
+ if len(self._data) >= self._max_size:
+ # 简单策略:删除一半的条目
+ keys_to_remove = list(self._data.keys())[:self._max_size // 2]
+ for k in keys_to_remove:
+ del self._data[k]
+
+ self._data[key] = value
+ self._stats["sets"] += 1
+ return True
+ except Exception as e:
+ self._logger.error(f"Failed to set cache: {e}")
+ return False
+
+ def delete(self, key: str) -> bool:
+ """删除缓存值"""
+ try:
+ if key in self._data:
+ del self._data[key]
+ self._stats["deletes"] += 1
+ return True
+ return False
+ except Exception as e:
+ self._logger.error(f"Failed to delete cache: {e}")
+ return False
+
+ def clear(self) -> bool:
+ """清空缓存"""
+ try:
+ self._data.clear()
+ self._stats = {
+ "hits": 0,
+ "misses": 0,
+ "sets": 0,
+ "deletes": 0
+ }
+ return True
+ except Exception as e:
+ self._logger.error(f"Failed to clear cache: {e}")
+ return False
+
+ def exists(self, key: str) -> bool:
+ """检查键是否存在"""
+ return key in self._data
+
+ def keys(self, pattern: str = "*") -> List[str]:
+ """获取匹配模式的键列表"""
+ import fnmatch
+ return [key for key in self._data.keys() if fnmatch.fnmatch(key, pattern)]
+
+ def get_info(self) -> Dict[str, Any]:
+ """获取缓存后端信息"""
+ return {
+ "type": "memory",
+ "items_count": len(self._data),
+ "max_size": self._max_size,
+ "stats": self._stats.copy(),
+ "memory_usage": sum(len(k) + len(str(v)) for k, v in self._data.items())
+ }
+
+ def cleanup(self):
+ """清理缓存后端"""
+ self.clear()
diff --git a/src/mcpstore/core/registry/core_registry/errors.py b/src/mcpstore/core/registry/core_registry/errors.py
new file mode 100644
index 00000000..66c5368e
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/errors.py
@@ -0,0 +1,19 @@
+from typing import Optional, Any
+
+ERROR_PREFIX = "[MCPSTORE_ERROR]"
+
+
+def raise_legacy_error(feature: str, detail: Optional[str] = None) -> None:
+ message = f"{ERROR_PREFIX} {feature} is disabled."
+ if detail:
+ message = f"{message} {detail}"
+ raise RuntimeError(message)
+
+
+class LegacyManagerProxy:
+ def __init__(self, name: str, detail: Optional[str] = None):
+ self._name = name
+ self._detail = detail
+
+ def __getattr__(self, attr: str) -> Any:
+ raise_legacy_error(f"{self._name}.{attr}", self._detail)
diff --git a/src/mcpstore/core/registry/core_registry/main_registry.py b/src/mcpstore/core/registry/core_registry/main_registry.py
new file mode 100644
index 00000000..542382a6
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/main_registry.py
@@ -0,0 +1,2114 @@
+"""
+ServiceRegistry - 主服务注册表门面类
+
+这是主门面类,legacy 接口已禁用,统一通过核心缓存管理器工作。
+"""
+
+import logging
+import time
+from datetime import datetime
+from typing import Dict, List, Optional, Any, Set, Tuple
+
+from mcpstore.core.models.service import ServiceConnectionState, ServiceStateMetadata
+# 导入所有管理器
+from .errors import ERROR_PREFIX, raise_legacy_error, LegacyManagerProxy
+
+
+class CacheBackedAgentClientService:
+ """
+ Agent-Client 映射服务(新架构)
+
+ 所有数据来源于关系管理器(pykv 唯一真相源)。
+ """
+
+ def __init__(self, registry: 'ServiceRegistry'):
+ self._registry = registry
+ self._relation_manager = registry._relation_manager
+ self._run_async = registry._run_async
+ self._logger = logging.getLogger(f"{__name__}.AgentClient")
+
+ def add_agent_client_mapping(self, agent_id: str, client_id: str) -> bool:
+ """
+ Agent-Client 映射由服务映射派生,这里仅保留方法以保持 API 自洽。
+ """
+ self._logger.debug(
+ "[AGENT_CLIENT] add_agent_client_mapping is a no-op (derived from service mappings) "
+ "agent_id=%s client_id=%s", agent_id, client_id
+ )
+ return True
+
+ def remove_agent_client_mapping(self, agent_id: str, client_id: str) -> bool:
+ """见 add_agent_client_mapping 说明。"""
+ self._logger.debug(
+ "[AGENT_CLIENT] remove_agent_client_mapping is a no-op agent_id=%s client_id=%s",
+ agent_id,
+ client_id,
+ )
+ return True
+
+ def add_service_client_mapping(self, agent_id: str, service_name: str, client_id: str) -> bool:
+ return self._registry.set_service_client_mapping(agent_id, service_name, client_id)
+
+ def remove_service_client_mapping(self, agent_id: str, service_name: str) -> bool:
+ return self._registry.remove_service_client_mapping(agent_id, service_name)
+
+ def get_service_client_id(self, agent_id: str, service_name: str) -> Optional[str]:
+ return self._registry.get_service_client_id(agent_id, service_name)
+
+ async def get_service_client_id_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 异步获取 service -> client_id 映射。
+
+ 直接委托给 ServiceRegistry,保持 pyKV 作为唯一数据源。
+ """
+ return await self._registry.get_service_client_id_async(agent_id, service_name)
+
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]:
+ return await self._registry.get_agent_clients_async(agent_id)
+
+ def get_service_client_mapping(self, agent_id: str) -> Dict[str, str]:
+ """
+ 获取 agent 下所有服务与 client_id 的映射。
+
+ 同时返回本地名称和全局名称,保证旧代码能识别。
+ """
+ return self._run_async(
+ self.get_service_client_mapping_async(agent_id),
+ op_name="AgentClientService.get_service_client_mapping",
+ )
+
+ async def get_service_client_mapping_async(self, agent_id: str) -> Dict[str, str]:
+ mapping: Dict[str, str] = {}
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ client_id = svc.get("client_id")
+ if not client_id:
+ continue
+ original = svc.get("service_original_name")
+ global_name = svc.get("service_global_name")
+ if original:
+ mapping[original] = client_id
+ if global_name and global_name != original:
+ mapping[global_name] = client_id
+ return mapping
+
+
+class CacheBackedServiceStateService:
+ """
+ ServiceStateService 新实现
+
+ 直接委托给 ServiceRegistry 的新式接口,确保所有数据来自 pykv。
+ """
+
+ def __init__(self, registry: 'ServiceRegistry'):
+ self._registry = registry
+ self._logger = logging.getLogger(f"{__name__}.ServiceState")
+
+ def get_service_state(self, agent_id: str, service_name: str) -> Optional[Any]:
+ return self._registry.get_service_state(agent_id, service_name)
+
+ def set_service_state(self, agent_id: str, service_name: str, state: Any) -> bool:
+ return self._registry.set_service_state(agent_id, service_name, state)
+
+ async def get_service_state_async(self, agent_id: str, service_name: str) -> Optional[Any]:
+ return await self._registry.get_service_state_async(agent_id, service_name)
+
+ async def delete_service_state_async(self, agent_id: str, service_name: str) -> bool:
+ return await self._registry.delete_service_state_async(agent_id, service_name)
+
+ def get_all_service_names(self, agent_id: str) -> List[str]:
+ return self._registry.get_all_service_names(agent_id)
+
+ async def get_all_service_names_async(self, agent_id: str) -> List[str]:
+ """
+ 异步获取指定 agent_id 下所有已注册服务名。
+
+ [pykv 唯一真相源] 委托给 ServiceRegistry 的异步方法从 pykv 读取。
+ """
+ return await self._registry.get_all_service_names_async(agent_id)
+
+ def clear_service_state(self, agent_id: str, service_name: str) -> bool:
+ return self._registry.clear_service_state(agent_id, service_name)
+
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata: Any) -> bool:
+ return self._registry.set_service_metadata(agent_id, service_name, metadata)
+
+ async def get_service_metadata_async(self, agent_id: str, service_name: str) -> Optional[Any]:
+ return await self._registry.get_service_metadata_async(agent_id, service_name)
+
+ async def delete_service_metadata_async(self, agent_id: str, service_name: str) -> bool:
+ return await self._registry.delete_service_metadata_async(agent_id, service_name)
+
+
+class ServiceRegistry:
+ """
+ 主服务注册表门面类
+
+ 通过门面模式整合所有专门管理器,提供统一的接口。
+ legacy 方法已禁用,调用将直接报错。
+ """
+
+ def __init__(self,
+ kv_store: Optional['AsyncKeyValue'] = None,
+ namespace: str = "mcpstore"):
+ """
+ Initialize ServiceRegistry with new cache architecture.
+
+ Args:
+ kv_store: AsyncKeyValue instance for data storage (required).
+ Session data is always kept in memory regardless of kv_store type.
+ namespace: Cache namespace for data isolation (default: "mcpstore")
+
+ Note:
+ - Sessions are stored in memory (not serializable)
+ - All other data uses the new three-layer cache architecture
+ - Uses CacheLayerManager for all cache operations
+ """
+ self._config = {}
+ self._kv_store = self._create_cache_layer(kv_store)
+ self._namespace = namespace
+ self._logger = logging.getLogger(__name__)
+
+ # 创建缓存层和命名服务
+ naming_service = self._create_naming_service()
+ from mcpstore.core.cache.cache_layer_manager import CacheLayerManager
+ cache_layer_manager = CacheLayerManager(self._kv_store, namespace)
+
+ # 统一缓存入口
+ self._cache_layer = cache_layer_manager
+ self._naming = naming_service
+
+ # 会话存储(内存中)
+ self.sessions: Dict[str, Dict[str, Any]] = {}
+ self.service_states: Dict[str, Dict[str, Any]] = {}
+ self.service_metadata: Dict[str, Dict[str, Any]] = {}
+
+ # 统一配置管理器
+ self._unified_config = None
+
+ # 同步助手(懒加载)
+ self._sync_helper: Optional[Any] = None
+
+ # 状态同步管理器
+ self._state_sync_manager = None
+
+ self._coordinator = LegacyManagerProxy(
+ "core_registry.ManagerCoordinator",
+ "ManagerCoordinator is disabled; use CacheLayerManager.",
+ )
+ from mcpstore.core.registry.core_registry.session_manager import SessionManager
+ self._session_manager = SessionManager(cache_layer_manager, naming_service, namespace)
+ self.sessions = self._session_manager.sessions
+ self._tool_manager = LegacyManagerProxy(
+ "core_registry.ToolManager",
+ "ToolManager is disabled; use core/cache tool managers.",
+ )
+ self._cache_manager = LegacyManagerProxy(
+ "core_registry.CacheManager",
+ "CacheManager is disabled; use CacheLayerManager.",
+ )
+ # 缓存同步状态记录(初始化时间/同步来源等),供初始化/同步流程写入
+ self.cache_sync_status: Dict[str, Any] = {}
+ # 缓存是否已完成初始化的标记(单一数据源模式使用)
+ self.cache_initialized: bool = False
+ self._persistence_manager = LegacyManagerProxy(
+ "core_registry.PersistenceManager",
+ "PersistenceManager is disabled; use core/cache shells.",
+ )
+ self._service_manager = LegacyManagerProxy(
+ "core_registry.ServiceManager",
+ "ServiceManager is disabled; use core/cache service managers.",
+ )
+
+ self._mapping_manager = LegacyManagerProxy(
+ "core_registry.MappingManager",
+ "MappingManager is disabled; use core/cache relationship managers.",
+ )
+
+ # 创建缓存层管理器(原始架构中的核心组件)
+ # 这些管理器直接操作 pykv,是数据的唯一真相源
+ from mcpstore.core.cache.service_entity_manager import ServiceEntityManager
+ from mcpstore.core.cache.tool_entity_manager import ToolEntityManager
+ from mcpstore.core.cache.state_manager import StateManager as CacheStateManager
+ from mcpstore.core.cache.relationship_manager import RelationshipManager
+
+ # 缓存层实体管理器(用于直接操作 pykv)
+ self._cache_service_manager = ServiceEntityManager(cache_layer_manager, naming_service)
+ self._cache_tool_manager = ToolEntityManager(cache_layer_manager, naming_service)
+ self._cache_state_manager = CacheStateManager(cache_layer_manager)
+ self._state_manager = self._cache_state_manager
+ self._cache_layer_manager = cache_layer_manager
+
+ # 创建关系管理器(使用 CacheLayerManager)
+ self._relation_manager = RelationshipManager(cache_layer_manager)
+ self._logger.debug("Cache layer manager initialization successful")
+
+ # 映射管理器已禁用
+
+ # 面向核心模块的 façade,统一通过新的缓存管理器实现
+ self._service_state_service = CacheBackedServiceStateService(self)
+ self._agent_client_service = CacheBackedAgentClientService(self)
+
+ self._logger.info("ServiceRegistry initialized with all managers")
+
+ async def switch_backend(self, kv_store, namespace: Optional[str] = None) -> bool:
+ """
+ 运行时切换底层 KV 存储,并重建缓存管理器
+
+ Args:
+ kv_store: 新的 AsyncKeyValue 实例(MemoryStore 或 RedisStore)
+ namespace: 可选命名空间,默认沿用当前设置
+
+ Returns:
+ bool: 切换是否成功
+
+ Raises:
+ ValueError: 当 kv_store 为空时抛出
+ """
+ if kv_store is None:
+ raise ValueError("kv_store cannot be empty, must provide a valid AsyncKeyValue instance")
+
+ ns = namespace or self._namespace
+
+ # 记录旧的缓存层以便迁移数据
+ old_cache_layer = getattr(self, "_cache_layer_manager", None)
+
+ # 重新构建 CacheLayer 及相关管理器,确保所有读写都指向新的后端
+ from mcpstore.core.cache.cache_layer_manager import CacheLayerManager
+ from mcpstore.core.cache.service_entity_manager import ServiceEntityManager
+ from mcpstore.core.cache.tool_entity_manager import ToolEntityManager
+ from mcpstore.core.cache.state_manager import StateManager as CacheStateManager
+ from mcpstore.core.cache.relationship_manager import RelationshipManager
+ from mcpstore.core.registry.core_registry.session_manager import SessionManager
+
+ self._kv_store = kv_store
+ self._namespace = ns
+ self._cache_layer = CacheLayerManager(kv_store, ns)
+ self._cache_layer_manager = self._cache_layer
+
+ # 保持同一命名服务实例
+ naming_service = self._naming or self._create_naming_service()
+
+ self._cache_service_manager = ServiceEntityManager(self._cache_layer, naming_service)
+ self._cache_tool_manager = ToolEntityManager(self._cache_layer, naming_service)
+ self._cache_state_manager = CacheStateManager(self._cache_layer)
+ self._state_manager = self._cache_state_manager
+ self._relation_manager = RelationshipManager(self._cache_layer)
+
+ # 会话管理器依赖新的 cache_layer
+ self._session_manager = SessionManager(self._cache_layer, naming_service, ns)
+ self.sessions = self._session_manager.sessions
+
+ # 尝试迁移旧缓存中的实体/关系/状态,避免切换后需要重新添加服务
+ try:
+ if old_cache_layer:
+ migrate_entities = 0
+ migrate_relations = 0
+ migrate_states = 0
+
+ entity_types = ["services", "tools", "agents", "store", "clients"]
+ for et in entity_types:
+ data = await old_cache_layer.get_all_entities_async(et)
+ for k, v in (data or {}).items():
+ await self._cache_layer_manager.put_entity(et, k, v)
+ migrate_entities += 1
+
+ relation_types = ["agent_services", "service_tools"]
+ for rt in relation_types:
+ data = await old_cache_layer.get_all_relations_async(rt)
+ for k, v in (data or {}).items():
+ await self._cache_layer_manager.put_relation(rt, k, v)
+ migrate_relations += 1
+
+ state_types = ["service_status", "service_metadata"]
+ for st in state_types:
+ data = await old_cache_layer.get_all_states_async(st)
+ for k, v in (data or {}).items():
+ await self._cache_layer_manager.put_state(st, k, v)
+ migrate_states += 1
+
+ self._logger.info(
+ "[SWITCH_BACKEND] Migrated cache data to new backend: "
+ "entities=%d relations=%d states=%d namespace=%s backend=%s",
+ migrate_entities,
+ migrate_relations,
+ migrate_states,
+ ns,
+ type(kv_store).__name__,
+ )
+ except Exception as migrate_err:
+ self._logger.warning(
+ "[SWITCH_BACKEND] Cache migration failed: %s", migrate_err, exc_info=True
+ )
+
+ self._logger.info("Registry backend switched successfully: namespace=%s, backend=%s", ns, type(kv_store).__name__)
+ return True
+
+ async def _ensure_agent_entity(self, agent_id: str) -> None:
+ """
+ 确保 Agent 实体存在;若已存在则刷新最后活跃时间。
+ """
+ if not agent_id:
+ return
+ try:
+ now_ts = int(time.time())
+ agent = await self._cache_layer_manager.get_agent(agent_id)
+ if agent is None:
+ await self._cache_layer_manager.create_agent(
+ agent_id=agent_id,
+ created_time=now_ts,
+ is_global=(agent_id == self._naming.GLOBAL_AGENT_STORE)
+ )
+ self._logger.info(f"[AGENT] Created agent entity: {agent_id}")
+ else:
+ await self._cache_layer_manager.update_agent_last_active(agent_id, now_ts)
+ except Exception as e:
+ self._logger.warning(f"[AGENT] ensure_agent_entity failed for {agent_id}: {e}")
+
+ def _legacy(self, method: str) -> None:
+ raise_legacy_error(
+ f"ServiceRegistry.{method}",
+ "Legacy interface disabled; use core/cache managers and shells.",
+ )
+
+ def _create_cache_layer(self, kv_store=None):
+ """
+ 创建缓存层
+
+ Args:
+ kv_store: AsyncKeyValue 实例,必须提供
+
+ Returns:
+ 传入的 kv_store 实例
+
+ Raises:
+ RuntimeError: 如果 kv_store 为 None
+ """
+ if kv_store is None:
+ raise RuntimeError(
+ f"{ERROR_PREFIX} kv_store 参数不能为 None。"
+ "ServiceRegistry 必须传入有效的 AsyncKeyValue 实例。"
+ "请使用 MemoryStore 或 RedisStore 初始化。"
+ )
+ return kv_store
+
+ def _create_naming_service(self):
+ """创建命名服务"""
+ # 优先使用真正的 NamingService
+ try:
+ from mcpstore.core.cache.naming_service import NamingService
+ return NamingService()
+ except ImportError:
+ raise RuntimeError(
+ f"{ERROR_PREFIX} NamingService import failed; no fallback is allowed."
+ )
+
+ def _run_async(self, coro, op_name: str):
+ from mcpstore.core.bridge import get_async_bridge
+
+ return get_async_bridge().run(coro, op_name=op_name)
+
+ def _map_health_status(self, health_status: Any):
+ if isinstance(health_status, ServiceConnectionState):
+ return health_status
+ if isinstance(health_status, str):
+ try:
+ return ServiceConnectionState(health_status)
+ except ValueError as exc:
+ raise RuntimeError(
+ f"{ERROR_PREFIX} Invalid service health_status: {health_status}"
+ ) from exc
+ raise RuntimeError(
+ f"{ERROR_PREFIX} Invalid service health_status type: {type(health_status).__name__}"
+ )
+
+ def _cache_state_snapshot(self, agent_id: str, service_name: str, state_value: Optional[Any]) -> None:
+ """
+ 维护内存中的运行时状态快照,供共享 client 状态同步和事务快照使用。
+ """
+ if not agent_id or not service_name:
+ return
+ if state_value is None:
+ agent_states = self.service_states.get(agent_id)
+ if agent_states and service_name in agent_states:
+ agent_states.pop(service_name, None)
+ if not agent_states:
+ self.service_states.pop(agent_id, None)
+ return
+ mapped_state = self._map_health_status(state_value)
+ self.service_states.setdefault(agent_id, {})[service_name] = mapped_state
+
+ def _cache_metadata_snapshot(self, agent_id: str, service_name: str, metadata: Optional[Any]) -> None:
+ """
+ 维护内存中的服务元数据快照。
+ """
+ if not agent_id or not service_name:
+ return
+ if metadata is None:
+ agent_meta = self.service_metadata.get(agent_id)
+ if agent_meta and service_name in agent_meta:
+ agent_meta.pop(service_name, None)
+ if not agent_meta:
+ self.service_metadata.pop(agent_id, None)
+ return
+ if isinstance(metadata, ServiceStateMetadata):
+ metadata_obj = metadata
+ elif hasattr(metadata, "model_dump"):
+ metadata_obj = ServiceStateMetadata.model_validate(metadata.model_dump())
+ elif isinstance(metadata, dict):
+ metadata_obj = ServiceStateMetadata.model_validate(metadata)
+ else:
+ # 无法识别的类型,直接存储原值,便于调试
+ metadata_obj = metadata
+ self.service_metadata.setdefault(agent_id, {})[service_name] = metadata_obj
+
+ async def _resolve_global_name_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not service_name:
+ raise ValueError("Service name cannot be empty")
+
+ if agent_id == self._naming.GLOBAL_AGENT_STORE:
+ return service_name
+
+ if self._naming.AGENT_SEPARATOR in service_name:
+ global_name = service_name
+ else:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_original_name") == service_name:
+ return svc.get("service_global_name")
+ return None
+
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_global_name") == global_name:
+ return global_name
+ return None
+
+ # ========================================
+ # 会话管理方法 (委托给SessionManager)
+ # ========================================
+
+ async def initialize(self) -> None:
+ """初始化所有管理器"""
+ self._legacy("initialize")
+
+ async def cleanup(self) -> None:
+ """清理所有管理器资源"""
+ self._legacy("cleanup")
+
+ def create_session(self, agent_id: str, session_type: str = "default",
+ metadata: Optional[Dict[str, Any]] = None) -> str:
+ return self._session_manager.create_session(agent_id, session_type, metadata)
+
+ async def create_session_async(self, agent_id: str, session_type: str = "default",
+ metadata: Optional[Dict[str, Any]] = None) -> str:
+ return await self._session_manager.create_session_async(agent_id, session_type, metadata)
+
+ def get_session(self, agent_id: str, name: str) -> Optional[Any]:
+ """
+ 获取指定agent_id下服务的会话对象
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 会话对象或None
+ """
+ return self._session_manager.get_session(agent_id, name)
+
+ def close_session(self, session_id: str) -> bool:
+ return self._session_manager.close_session(session_id)
+
+ async def close_session_async(self, session_id: str) -> bool:
+ return await self._session_manager.close_session_async(session_id)
+
+ def list_sessions(self, agent_id: Optional[str] = None) -> List[str]:
+ return self._session_manager.list_sessions(agent_id)
+
+ def add_tool_to_session(self, session_id: str, tool_name: str) -> bool:
+ return self._session_manager.add_tool_to_session(session_id, tool_name)
+
+ def remove_tool_from_session(self, session_id: str, tool_name: str) -> bool:
+ return self._session_manager.remove_tool_from_session(session_id, tool_name)
+
+ def get_session_tools(self, session_id: str) -> Set[str]:
+ return self._session_manager.get_session_tools(session_id)
+
+ def clear_agent_sessions(self, agent_id: str) -> None:
+ raise_legacy_error(
+ "ServiceRegistry.clear_agent_sessions",
+ "Use session_manager.clear_all_sessions via the cache-backed architecture.",
+ )
+
+ def clear(self, agent_id: str) -> bool:
+ """
+ 同步清空指定 Agent 的所有注册信息。
+ """
+ return self._run_async(
+ self.clear_async(agent_id),
+ op_name="ServiceRegistry.clear",
+ )
+
+ async def clear_async(self, agent_id: str) -> bool:
+ """
+ 异步清空指定 Agent 的所有注册信息。
+
+ Args:
+ agent_id: Agent ID
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ services = await self._relation_manager.get_agent_services(agent_id)
+ client_ids: Set[str] = set()
+ seen: Set[str] = set()
+ for svc in services:
+ service_name = svc.get("service_original_name") or svc.get("service_global_name")
+ cid = svc.get("client_id")
+ if cid:
+ client_ids.add(cid)
+ if not service_name or service_name in seen:
+ continue
+ seen.add(service_name)
+ try:
+ await self.remove_service_async(agent_id, service_name)
+ except Exception as exc:
+ self._logger.warning(
+ "Failed to remove service '%s' for agent '%s' during clear_async: %s",
+ service_name,
+ agent_id,
+ exc,
+ )
+
+ # 清理客户端实体(避免留下孤立 client 记录)
+ try:
+ # 关系层可能包含更多 client_id,合并一次
+ rel_client_ids = await self.get_agent_clients_async(agent_id)
+ client_ids.update(rel_client_ids)
+ for cid in client_ids:
+ await self._cache_layer_manager.delete_entity("clients", cid)
+ except Exception as exc:
+ self._logger.warning("Failed to cleanup clients for agent '%s': %s", agent_id, exc)
+
+ self.service_states.pop(agent_id, None)
+ self.service_metadata.pop(agent_id, None)
+ self.sessions.pop(agent_id, None)
+ if self._session_manager:
+ self._session_manager.clear_all_sessions(agent_id)
+ return True
+
+ # ========================================
+ # 服务管理方法 (委托给ServiceManager)
+ # ========================================
+
+ def add_service(self, agent_id: str, name: str, session: Any = None,
+ tools: List[tuple] = None, service_config: Dict[str, Any] = None,
+ auto_connect: bool = True) -> bool:
+ """
+ 添加服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+ session: 服务会话对象
+ tools: 工具列表 [(tool_name, tool_def)]
+ service_config: 服务配置
+ auto_connect: 是否自动连接
+
+ Returns:
+ 是否成功添加
+ """
+ return self._run_async(
+ self.add_service_async(
+ agent_id=agent_id,
+ name=name,
+ session=session,
+ tools=tools,
+ service_config=service_config,
+ auto_connect=auto_connect,
+ ),
+ op_name="ServiceRegistry.add_service",
+ )
+
+ async def add_service_async(self, agent_id: str, name: str, session: Any = None,
+ tools: List[tuple] = None, service_config: Dict[str, Any] = None,
+ auto_connect: bool = True, preserve_mappings: bool = False,
+ state: Any = None, client_id: Optional[str] = None) -> bool:
+ """
+ 异步添加服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+ session: 服务会话对象
+ tools: 工具列表 [(tool_name, tool_def)]
+ service_config: 服务配置
+ auto_connect: 是否自动连接
+ preserve_mappings: 是否保留已有的映射关系
+ state: 服务状态(可选)
+
+ Returns:
+ 是否成功添加
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not name:
+ raise ValueError("Service name cannot be empty")
+
+ tools = tools or []
+ service_config = service_config or {}
+
+ # 确保 Agent 实体存在(无论全局还是普通 Agent)
+ await self._ensure_agent_entity(agent_id)
+
+ service_global_name = await self._cache_service_manager.create_service(
+ agent_id=agent_id,
+ original_name=name,
+ config=service_config
+ )
+
+ existing_client_id = None
+ if preserve_mappings:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_global_name") == service_global_name or svc.get("service_original_name") == name:
+ existing_client_id = svc.get("client_id")
+ break
+
+ if existing_client_id:
+ client_id = existing_client_id
+ if not client_id:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=name,
+ service_config=service_config,
+ global_agent_store_id=self._naming.GLOBAL_AGENT_STORE,
+ )
+
+ # 写入/更新 clients 实体
+ import time
+ now_ts = int(time.time())
+ client_entity = await self._cache_layer_manager.get_entity("clients", client_id)
+ if not isinstance(client_entity, dict):
+ client_entity = {
+ "client_id": client_id,
+ "agent_id": agent_id,
+ "services": [],
+ "created_time": now_ts,
+ }
+ services_list = client_entity.get("services") or []
+ if service_global_name not in services_list:
+ services_list.append(service_global_name)
+ client_entity.update({
+ "agent_id": agent_id,
+ "services": services_list,
+ "updated_time": now_ts,
+ })
+ await self._cache_layer_manager.put_entity("clients", client_id, client_entity)
+
+ await self._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=name,
+ service_global_name=service_global_name,
+ client_id=client_id
+ )
+
+ tools_status = []
+ for tool in tools:
+ if isinstance(tool, tuple) and len(tool) == 2:
+ tool_name, tool_def = tool
+ elif isinstance(tool, dict):
+ tool_name = tool.get("name")
+ tool_def = tool
+ else:
+ raise ValueError(f"Invalid tool definition: {tool}")
+
+ # 提取工具原始名称(去除服务前缀)
+ # 注意:MCP 服务返回的工具名称可能已经带有服务前缀
+ # 例如:mcpstore_get_current_weather -> get_current_weather
+ from mcpstore.core.logic.tool_logic import ToolLogicCore
+ original_tool_name = ToolLogicCore.extract_original_tool_name(
+ tool_name, service_global_name
+ )
+
+ tool_global_name = await self._cache_tool_manager.create_tool(
+ service_global_name=service_global_name,
+ service_original_name=name,
+ source_agent=agent_id,
+ tool_original_name=original_tool_name,
+ tool_def=tool_def
+ )
+ await self._relation_manager.add_service_tool(
+ service_global_name=service_global_name,
+ service_original_name=name,
+ source_agent=agent_id,
+ tool_global_name=tool_global_name,
+ tool_original_name=original_tool_name
+ )
+ tools_status.append({
+ "tool_global_name": tool_global_name,
+ "tool_original_name": original_tool_name,
+ "status": "available"
+ })
+
+ if state is None:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.INITIALIZING
+
+ health_status = state.value if hasattr(state, "value") else str(state)
+ await self._cache_state_manager.update_service_status(
+ service_global_name=service_global_name,
+ health_status=health_status,
+ tools_status=tools_status
+ )
+ # 初始化 service_metadata 状态
+ try:
+ metadata_state = {
+ "service_global_name": service_global_name,
+ "agent_id": agent_id,
+ "created_time": now_ts,
+ "state_entered_time": now_ts,
+ "reconnect_attempts": 0,
+ "last_ping_time": None,
+ }
+ await self._cache_layer_manager.put_state("service_metadata", service_global_name, metadata_state)
+ except Exception as meta_error:
+ logger.warning(f"[SERVICE_METADATA] init metadata failed for {service_global_name}: {meta_error}")
+
+ self._cache_state_snapshot(agent_id, name, state)
+
+ if session is not None:
+ self._session_manager.set_session(agent_id, name, session)
+
+ return True
+
+ async def remove_service_async(self, agent_id: str, name: str) -> Optional[Any]:
+ """
+ 异步移除服务(代理到 ServiceManager)
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 被移除的会话对象
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+ if not name:
+ raise ValueError("Service name cannot be empty")
+
+ global_name = await self._resolve_global_name_async(agent_id, name)
+ if not global_name:
+ return None
+
+ tool_relations = await self._relation_manager.get_service_tools(global_name)
+
+ await self._relation_manager.remove_service_cascade(agent_id, global_name)
+ await self._cache_layer_manager.delete_entity("services", global_name)
+ await self._cache_layer_manager.delete_state("service_status", global_name)
+ await self._cache_layer_manager.delete_state("service_metadata", global_name)
+
+ for tool in tool_relations:
+ tool_global_name = tool.get("tool_global_name")
+ if tool_global_name:
+ await self._cache_tool_manager.delete_tool(tool_global_name)
+
+ if self._session_manager:
+ self._session_manager.clear_session(agent_id, name)
+
+ self._cache_state_snapshot(agent_id, name, None)
+ self._cache_metadata_snapshot(agent_id, name, None)
+
+ return None
+
+ def register_service(self, service_config: Dict[str, Any]) -> bool:
+ return self._service_manager.register_service(service_config)
+
+ async def register_service_async(self, service_config: Dict[str, Any]) -> bool:
+ return await self._service_manager.register_service_async(service_config)
+
+ def unregister_service(self, service_name: str) -> bool:
+ return self._service_manager.unregister_service(service_name)
+
+ async def unregister_service_async(self, service_name: str) -> bool:
+ return await self._service_manager.unregister_service_async(service_name)
+
+ def get_service_details(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ info = self.get_complete_service_info(agent_id, service_name)
+ if not info:
+ return None
+ return {
+ "service_name": info.get("service_original_name"),
+ "service_global_name": info.get("service_global_name"),
+ "config": info.get("config", {}),
+ "state": info.get("state"),
+ "state_metadata": info.get("state_metadata"),
+ "state_entered_time": info.get("state_entered_time"),
+ "last_heartbeat": info.get("last_heartbeat"),
+ "client_id": info.get("client_id"),
+ "tools": info.get("tools", []),
+ "tool_count": info.get("tool_count", 0),
+ }
+
+ def get_services_for_agent(self, agent_id: str) -> List[str]:
+ return self._run_async(
+ self.get_services_for_agent_async(agent_id),
+ op_name="ServiceRegistry.get_services_for_agent",
+ )
+
+ def is_service_registered(self, service_name: str) -> bool:
+ entity = self._run_async(
+ self._cache_layer_manager.get_entity("services", service_name),
+ op_name="ServiceRegistry.is_service_registered",
+ )
+ return entity is not None
+
+ def has_service(self, agent_id: str, service_name: str) -> bool:
+ """
+ 检查指定 Agent 是否拥有指定服务
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务是否存在
+ """
+ return self._run_async(
+ self.has_service_async(agent_id, service_name),
+ op_name="ServiceRegistry.has_service",
+ )
+
+ async def has_service_async(self, agent_id: str, service_name: str) -> bool:
+ """
+ 异步检查指定 Agent 是否拥有指定服务
+
+ 遵循 "Functional Core, Imperative Shell" 架构原则:
+ - 异步外壳直接使用 await 调用异步操作
+ - 在异步上下文中必须使用此方法,而非同步版本
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务是否存在
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return False
+ entity = await self._cache_layer_manager.get_entity("services", global_name)
+ return entity is not None
+
+ async def get_services_for_agent_async(self, agent_id: str) -> List[str]:
+ """
+ 异步获取指定 Agent 的所有服务
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ services = await self._relation_manager.get_agent_services(agent_id)
+ return [svc.get("service_original_name") for svc in services if svc.get("service_original_name")]
+
+ def get_all_services(self) -> List[str]:
+ services = self._run_async(
+ self._cache_layer_manager.get_all_entities_async("services"),
+ op_name="ServiceRegistry.get_all_services",
+ )
+ return list(services.keys())
+
+ def get_service_count(self) -> int:
+ return len(self.get_all_services())
+
+ def update_service_config(self, service_name: str, updates: Dict[str, Any]) -> bool:
+ return self._run_async(
+ self.update_service_config_async(service_name, updates),
+ op_name="ServiceRegistry.update_service_config",
+ )
+
+ async def update_service_config_async(self, service_name: str, updates: Dict[str, Any]) -> bool:
+ if not service_name:
+ raise ValueError("Service name cannot be empty")
+ if not isinstance(updates, dict):
+ raise ValueError("updates must be a dictionary type")
+
+ entity = await self._cache_layer_manager.get_entity("services", service_name)
+ if entity is None:
+ return False
+ config = entity.get("config", {})
+ if not isinstance(config, dict):
+ config = {}
+ config.update(updates)
+ entity["config"] = config
+ await self._cache_layer_manager.put_entity("services", service_name, entity)
+ return True
+
+ def get_service_config(self, service_name: str) -> Optional[Dict[str, Any]]:
+ entity = self._run_async(
+ self._cache_layer_manager.get_entity("services", service_name),
+ op_name="ServiceRegistry.get_service_config",
+ )
+ if not entity:
+ return None
+ return entity.get("config")
+
+ def get_service_config_from_cache(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """
+ 从缓存获取指定 Agent 下的服务配置(同步入口)
+
+ 语义:以命名服务解析全局名,再从实体层读取配置,避免绕过注册中心。
+ """
+ return self._run_async(
+ self.get_service_config_from_cache_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_service_config_from_cache",
+ )
+
+ async def get_service_config_from_cache_async(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """
+ 从缓存获取指定 Agent 下的服务配置(异步入口)
+
+ - 使用命名服务生成全局名,确保视角一致
+ - 通过 CacheLayerManager 读取实体层配置,保持单一数据源
+ """
+ if not agent_id:
+ raise ValueError("agent_id 不能为空")
+ if not service_name:
+ raise ValueError("service_name 不能为空")
+
+ info = await self.get_complete_service_info_async(agent_id, service_name)
+ if not info:
+ return None
+
+ config = info.get("config")
+ if config is None:
+ return None
+ if not isinstance(config, dict):
+ raise RuntimeError(
+ f"服务配置格式无效,期望 dict,实际类型 {type(config).__name__} "
+ f"(agent_id={agent_id}, service_name={service_name})"
+ )
+
+ return config
+
+ def get_service_summary(self, service_name: str) -> Optional[Dict[str, Any]]:
+ info = self.get_complete_service_info(self._naming.GLOBAL_AGENT_STORE, service_name)
+ if not info:
+ return None
+ return {
+ "service_name": info.get("service_original_name"),
+ "service_global_name": info.get("service_global_name"),
+ "state": info.get("state"),
+ "tool_count": info.get("tool_count", 0),
+ "client_id": info.get("client_id"),
+ }
+
+ async def get_service_summary_async(self, service_name: str) -> Optional[Dict[str, Any]]:
+ info = await self.get_complete_service_info_async(self._naming.GLOBAL_AGENT_STORE, service_name)
+ if not info:
+ return None
+ return {
+ "service_name": info.get("service_original_name"),
+ "service_global_name": info.get("service_global_name"),
+ "state": info.get("state"),
+ "tool_count": info.get("tool_count", 0),
+ "client_id": info.get("client_id"),
+ }
+
+ def get_complete_service_info(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取服务的完整信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务完整信息字典
+ """
+ return self._run_async(
+ self.get_complete_service_info_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_complete_service_info",
+ )
+
+ async def get_complete_service_info_async(self, agent_id: str, service_name: str) -> Optional[Dict[str, Any]]:
+ """
+ 异步获取服务的完整信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务完整信息字典
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return None
+
+ entity = await self._cache_layer_manager.get_entity("services", global_name)
+ if not entity:
+ return None
+
+ config = entity.get("config", {}) if isinstance(entity, dict) else {}
+ service_original_name = entity.get("service_original_name", service_name)
+
+ state = None
+ status = await self._cache_state_manager.get_service_status(global_name)
+ if status is not None:
+ health_status = status.health_status if hasattr(status, "health_status") else status.get("health_status")
+ state = self._map_health_status(health_status)
+
+ metadata = await self._cache_layer_manager.get_state("service_metadata", global_name)
+ metadata_obj = None
+ if metadata:
+ from mcpstore.core.models.service import ServiceStateMetadata
+ metadata_obj = ServiceStateMetadata.model_validate(metadata)
+
+ client_id = None
+ agent_services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in agent_services:
+ if svc.get("service_global_name") == global_name:
+ client_id = svc.get("client_id")
+ break
+
+ tool_relations = await self._relation_manager.get_service_tools(global_name)
+ tool_global_names = [
+ tool.get("tool_global_name")
+ for tool in tool_relations
+ if tool.get("tool_global_name")
+ ]
+ tool_entities = await self._cache_tool_manager.get_many_tools(tool_global_names) if tool_global_names else []
+
+ tools_info: List[Dict[str, Any]] = []
+ if tool_entities:
+ from mcpstore.core.logic.tool_logic import ToolInfo as ToolInfoCore
+ for tool_entity in tool_entities:
+ if tool_entity is None:
+ continue
+ entity_dict = tool_entity.to_dict() if hasattr(tool_entity, "to_dict") else tool_entity
+ tool_info = ToolInfoCore.from_entity(
+ entity_dict,
+ service_original_name,
+ global_name,
+ client_id=client_id
+ )
+ tools_info.append(tool_info.to_dict())
+
+ return {
+ "service_global_name": global_name,
+ "service_original_name": service_original_name,
+ "config": config,
+ "state": state,
+ "state_metadata": metadata_obj,
+ "state_entered_time": getattr(metadata_obj, "state_entered_time", None) if metadata_obj else None,
+ "last_heartbeat": getattr(metadata_obj, "last_ping_time", None) if metadata_obj else None,
+ "client_id": client_id,
+ "tools": tools_info,
+ "tool_count": len(tool_global_names),
+ }
+
+ def get_all_services_complete_info(self, agent_id: Optional[str] = None) -> List[Dict[str, Any]]:
+ async def _fetch_all():
+ effective_agent = agent_id or self._naming.GLOBAL_AGENT_STORE
+ services = await self._relation_manager.get_agent_services(effective_agent)
+ results: List[Dict[str, Any]] = []
+ for svc in services:
+ global_name = svc.get("service_global_name")
+ if not global_name:
+ continue
+ info = await self.get_complete_service_info_async(effective_agent, global_name)
+ if info:
+ results.append(info)
+ return results
+
+ return self._run_async(_fetch_all(), op_name="ServiceRegistry.get_all_services_complete_info")
+
+ # ========================================
+ # Legacy ServiceManager 方法 (已禁用)
+ # 这些方法委托给 LegacyManagerProxy,调用时会抛出错误
+ # ========================================
+
+ def clear_agent_lifecycle_data(self, agent_id: str) -> bool:
+ return self._service_manager.clear_agent_lifecycle_data(agent_id)
+
+ def get_stats(self) -> Dict[str, Any]:
+ return self._service_manager.get_stats()
+
+ def is_long_lived_service(self, service_name: str) -> bool:
+ return self._service_manager.is_long_lived_service(service_name)
+
+ def mark_as_long_lived(self, agent_id: str, service_name: str):
+ return self._service_manager.mark_as_long_lived(agent_id, service_name)
+
+ def set_long_lived_service(self, service_name: str, is_long_lived: bool) -> bool:
+ return self._service_manager.set_long_lived_service(service_name, is_long_lived)
+
+ def get_services_by_state(self, states: List[str]) -> List[str]:
+ return self._service_manager.get_services_by_state(states)
+
+ def get_healthy_services(self) -> List[str]:
+ return self._service_manager.get_healthy_services()
+
+ def get_failed_services(self) -> List[str]:
+ return self._service_manager.get_failed_services()
+
+ def get_services_with_tools(self) -> List[str]:
+ return self._service_manager.get_services_with_tools()
+
+ def should_cache_aggressively(self, service_name: str) -> bool:
+ return self._service_manager.should_cache_aggressively(service_name)
+
+ def remove_service_lifecycle_data(self, service_name: str, agent_id: str) -> bool:
+ return self._service_manager.remove_service_lifecycle_data(service_name, agent_id)
+
+ def set_service_lifecycle_data(self, service_name: str, agent_id: str, data: Dict[str, Any]) -> bool:
+ return self._service_manager.set_service_lifecycle_data(service_name, agent_id, data)
+
+ # ========================================
+ # 客户端映射方法 (委托给MappingManager)
+ # ========================================
+
+ async def get_service_client_id_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_original_name") == service_name or svc.get("service_global_name") == service_name:
+ return svc.get("client_id")
+ return None
+
+ def get_service_client_id(self, agent_id: str, service_name: str) -> Optional[str]:
+ return self._run_async(
+ self.get_service_client_id_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_service_client_id",
+ )
+
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]:
+ """
+ 从 pykv 关系层获取 Agent 的所有客户端
+
+ [pykv 唯一真相源] 所有数据必须从 pykv 读取
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 客户端ID列表
+ """
+ services = await self._relation_manager.get_agent_services(agent_id)
+ client_ids = {svc.get("client_id") for svc in services if svc.get("client_id")}
+ return list(client_ids)
+
+ def get_client_config_from_cache(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 从缓存获取客户端配置
+
+ Args:
+ client_id: 客户端ID
+
+ Returns:
+ 客户端配置或None
+ """
+ return self._run_async(
+ self.get_client_config_from_cache_async(client_id),
+ op_name="ServiceRegistry.get_client_config_from_cache",
+ )
+
+ async def get_client_config_from_cache_async(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 异步从缓存获取客户端配置
+
+ Args:
+ client_id: 客户端ID
+
+ Returns:
+ 客户端配置或None
+ """
+ return await self._cache_layer_manager.get_entity("clients", client_id)
+
+ def add_client_config(self, client_id: str, client_config: Dict[str, Any]) -> str:
+ if not client_id:
+ raise ValueError("client_id cannot be empty")
+ if not isinstance(client_config, dict):
+ raise ValueError("client_config must be a dictionary type")
+ self._run_async(
+ self._cache_layer_manager.put_entity("clients", client_id, client_config),
+ op_name="ServiceRegistry.add_client_config",
+ )
+ return client_id
+
+ def set_service_client_mapping(self, agent_id: str, service_name: str, client_id: str) -> bool:
+ return self._run_async(
+ self.set_service_client_mapping_async(agent_id, service_name, client_id),
+ op_name="ServiceRegistry.set_service_client_mapping",
+ )
+
+ async def set_service_client_mapping_async(self, agent_id: str, service_name: str, client_id: str) -> bool:
+ if not client_id:
+ raise ValueError("client_id cannot be empty")
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ global_name = self._naming.generate_service_global_name(service_name, agent_id)
+
+ if self._naming.AGENT_SEPARATOR in service_name:
+ service_original_name, _ = self._naming.parse_service_global_name(service_name)
+ else:
+ service_original_name = service_name
+
+ # 确保 clients 实体存在,并关联当前服务
+ import time
+ client_entity = await self._cache_layer_manager.get_entity("clients", client_id)
+ if not isinstance(client_entity, dict):
+ client_entity = {
+ "client_id": client_id,
+ "agent_id": agent_id,
+ "services": [],
+ "created_time": int(time.time()),
+ }
+ services = client_entity.get("services") or []
+ if global_name not in services:
+ services.append(global_name)
+ client_entity.update({
+ "agent_id": agent_id,
+ "services": services,
+ "updated_time": int(time.time()),
+ })
+ await self._cache_layer_manager.put_entity("clients", client_id, client_entity)
+
+ await self._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=service_original_name,
+ service_global_name=global_name,
+ client_id=client_id
+ )
+ return True
+
+ def remove_service_client_mapping(self, agent_id: str, service_name: str) -> bool:
+ return self._run_async(
+ self.delete_service_client_mapping_async(agent_id, service_name),
+ op_name="ServiceRegistry.remove_service_client_mapping",
+ )
+
+ async def delete_service_client_mapping_async(self, agent_id: str, service_name: str) -> bool:
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ global_name = self._naming.generate_service_global_name(service_name, agent_id)
+ await self._relation_manager.remove_agent_service(agent_id, global_name)
+ return True
+
+ def add_agent_service_mapping(self, agent_id: str, service_name: str, global_name: str) -> bool:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config={},
+ global_agent_store_id=self._naming.GLOBAL_AGENT_STORE,
+ )
+ self._run_async(
+ self._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=service_name,
+ service_global_name=global_name,
+ client_id=client_id,
+ ),
+ op_name="ServiceRegistry.add_agent_service_mapping",
+ )
+ return True
+
+ def get_global_name_from_agent_service(self, agent_id: str, service_name: str) -> Optional[str]:
+ return self._run_async(
+ self.get_global_name_from_agent_service_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_global_name_from_agent_service",
+ )
+
+ async def get_global_name_from_agent_service_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 依据 Agent 本地名解析全局服务名。
+ 优先使用关系表,缺失时回退到命名规则并校验实体存在,确保删除等场景不会因关系缺失而无法解析。
+ """
+ # 1) 关系表优先
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_original_name") == service_name or svc.get("service_global_name") == service_name:
+ return svc.get("service_global_name")
+
+ # 2) 已是全局名则直接返回
+ if self._naming.AGENT_SEPARATOR in service_name:
+ return service_name
+
+ # 3) 回退:按命名规则推导,并确认实体存在(避免误生成)
+ try:
+ candidate = self._naming.generate_service_global_name(service_name, agent_id)
+ exists = await self._cache_service_manager.get_service(candidate)
+ if exists:
+ logger.debug(
+ "[NAMING] Fallback global name resolved without relation: agent=%s, local=%s -> %s",
+ agent_id, service_name, candidate
+ )
+ return candidate
+ except Exception as resolve_error:
+ logger.debug(
+ "[NAMING] Failed to resolve fallback global name: agent=%s, local=%s, error=%s",
+ agent_id, service_name, resolve_error
+ )
+
+ return None
+
+ def get_agent_service_from_global_name(self, global_name: str) -> Optional[Tuple[str, str]]:
+ return self._run_async(
+ self.get_agent_service_from_global_name_async(global_name),
+ op_name="ServiceRegistry.get_agent_service_from_global_name",
+ )
+
+ async def get_agent_service_from_global_name_async(self, global_name: str) -> Optional[Tuple[str, str]]:
+ if not global_name:
+ raise ValueError("Service global name cannot be empty")
+ original_name, agent_id = self._naming.parse_service_global_name(global_name)
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ if svc.get("service_global_name") == global_name:
+ return (agent_id, svc.get("service_original_name") or original_name)
+ return None
+
+ async def get_agent_services_async(self, agent_id: str) -> List[str]:
+ """
+ 异步获取指定 Agent 的所有服务(返回全局服务名列表)
+ """
+ services = await self._relation_manager.get_agent_services(agent_id)
+ return [svc.get("service_global_name") for svc in services if svc.get("service_global_name")]
+
+ def get_agent_services(self, agent_id: str) -> List[str]:
+ return self._run_async(
+ self.get_agent_services_async(agent_id),
+ op_name="ServiceRegistry.get_agent_services",
+ )
+
+ def is_agent_service(self, agent_id: str, service_name: str) -> bool:
+ return self._naming.AGENT_SEPARATOR in service_name
+
+ def remove_agent_service_mapping(self, agent_id: str, service_name: str) -> bool:
+ """
+ 删除 Agent-Service 映射(同步接口);若在事件循环中则异步调度。
+ """
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ # 无事件循环,直接运行异步方法
+ return asyncio.run(self.remove_agent_service_mapping_async(agent_id, service_name))
+ else:
+ # 已有事件循环,调度异步任务立即返回
+ loop.create_task(self.remove_agent_service_mapping_async(agent_id, service_name))
+ return True
+
+ async def remove_agent_service_mapping_async(self, agent_id: str, service_name: str) -> bool:
+ """
+ 删除 Agent-Service 映射(异步版本,不做事件循环桥接)
+ - 仅清理映射表,不触发关系/状态删除
+ """
+ try:
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ except Exception:
+ global_name = None
+
+ # 清理关系层映射(如果需要)
+ if global_name:
+ try:
+ await self._relation_manager.remove_agent_service(agent_id, global_name)
+ except Exception:
+ pass
+
+ # 清理映射管理器缓存
+ try:
+ if self._service_manager and hasattr(self._service_manager, "remove_agent_service_mapping"):
+ self._service_manager.remove_agent_service_mapping(agent_id, service_name)
+ except Exception:
+ pass
+
+ return True
+
+ def clear_agent_mappings(self, agent_id: str) -> bool:
+ async def _clear():
+ services = await self._relation_manager.get_agent_services(agent_id)
+ for svc in services:
+ global_name = svc.get("service_global_name")
+ if global_name:
+ await self._relation_manager.remove_agent_service(agent_id, global_name)
+ return True
+
+ return self._run_async(_clear(), op_name="ServiceRegistry.clear_agent_mappings")
+
+ def clear_all_mappings(self) -> bool:
+ return self._legacy("clear_all_mappings")
+
+ def get_mapping_stats(self) -> Dict[str, Any]:
+ services = self._run_async(
+ self._cache_layer_manager.get_all_entities_async("services"),
+ op_name="ServiceRegistry.get_mapping_stats",
+ )
+ return {
+ "services_count": len(services),
+ "clients": len(
+ self._run_async(
+ self._cache_layer_manager.get_all_entities_async("clients"),
+ op_name="ServiceRegistry.get_mapping_stats.clients",
+ )
+ ),
+ }
+
+ # ========================================
+ # 工具管理方法 (委托给ToolManager)
+ # ========================================
+
+ def get_tools_for_service(self, agent_id: str, service_name: str) -> List[str]:
+ return self._run_async(
+ self.get_tools_for_service_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_tools_for_service",
+ )
+
+ async def get_tools_for_service_async(self, agent_id: str, service_name: str) -> List[str]:
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return []
+ tool_relations = await self._relation_manager.get_service_tools(global_name)
+ return [
+ tool.get("tool_global_name")
+ for tool in tool_relations
+ if tool.get("tool_global_name")
+ ]
+
+ def get_tool_info(self, agent_id: str, tool_name: str) -> Optional[Dict[str, Any]]:
+ return self._run_async(
+ self.get_tool_info_async(agent_id, tool_name),
+ op_name="ServiceRegistry.get_tool_info",
+ )
+
+ async def get_tool_info_async(self, agent_id: str, tool_name: str) -> Optional[Dict[str, Any]]:
+ tool_entity = await self._cache_tool_manager.get_tool(tool_name)
+ if tool_entity is None:
+ return None
+ entity_dict = tool_entity.to_dict() if hasattr(tool_entity, "to_dict") else tool_entity
+ service_global_name = entity_dict.get("service_global_name")
+ client_id = None
+ if service_global_name:
+ client_id = await self.get_service_client_id_async(agent_id, service_global_name)
+ return {
+ "name": entity_dict.get("tool_global_name"),
+ "display_name": entity_dict.get("tool_original_name"),
+ "tool_original_name": entity_dict.get("tool_original_name"),
+ "description": entity_dict.get("description", ""),
+ "service_name": entity_dict.get("service_original_name"),
+ "service_global_name": service_global_name,
+ "inputSchema": entity_dict.get("input_schema", {}),
+ "client_id": client_id,
+ }
+
+ def add_tool_to_service(self, service_name: str, tool_name: str, tool_config: Dict[str, Any]) -> bool:
+ return self._tool_manager.add_tool_to_service(service_name, tool_name, tool_config)
+
+ async def add_tool_to_service_async(self, service_name: str, tool_name: str, tool_config: Dict[str, Any]) -> bool:
+ return await self._tool_manager.add_tool_to_service_async(service_name, tool_name, tool_config)
+
+ def remove_tool_from_service(self, service_name: str, tool_name: str) -> bool:
+ return self._tool_manager.remove_tool_from_service(service_name, tool_name)
+
+ async def remove_tool_from_service_async(self, service_name: str, tool_name: str) -> bool:
+ return await self._tool_manager.remove_tool_from_service_async(service_name, tool_name)
+
+ def list_all_tools(self) -> List[str]:
+ return self._tool_manager.list_all_tools()
+
+ def search_tools(self, query: str, filters: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
+ return self._tool_manager.search_tools(query, filters)
+
+ def get_tools_stats(self) -> Dict[str, Any]:
+ return self._tool_manager.get_tools_stats()
+
+ def validate_tool_definition(self, tool_config: Dict[str, Any]) -> bool:
+ return self._tool_manager.validate_tool_definition(tool_config)
+
+ def get_tool_names_for_service(self, service_name: str) -> List[str]:
+ return self._tool_manager.get_tool_names_for_service(service_name)
+
+ def update_tool_info(self, service_name: str, tool_name: str, updates: Dict[str, Any]) -> bool:
+ return self._tool_manager.update_tool_info(service_name, tool_name, updates)
+
+ def clear_service_tools(self, service_name: str) -> bool:
+ return self._tool_manager.clear_service_tools(service_name)
+
+ def clear_service_tools_only(self, agent_id: str, service_name: str):
+ """
+ 只清理服务的工具缓存,保留Agent-Client映射关系
+
+ 这是优雅修复方案的核心方法:
+ - 清理工具缓存和工具-会话映射
+ - 保留Agent-Client映射
+ - 保留Client配置
+ - 保留Service-Client映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ try:
+ self._logger.debug(
+ f"[REGISTRY.CLEAR_TOOLS_ONLY] begin agent={agent_id} service={service_name}")
+
+ # 获取现有会话
+ existing_session = self._session_manager.get_session(agent_id, service_name)
+ if not existing_session:
+ self._logger.debug(f"[CLEAR_TOOLS] no_session service={service_name} skip=True")
+ return
+
+ # 只清理工具相关的缓存
+ tools_to_remove = []
+ all_tool_names = self._session_manager.get_all_tool_names(agent_id)
+ for tool_name in all_tool_names:
+ tool_session = self._session_manager.get_session_for_tool(agent_id, tool_name)
+ if tool_session is existing_session:
+ tools_to_remove.append(tool_name)
+
+ for tool_name in tools_to_remove:
+ # 清理工具-会话映射
+ self._session_manager.remove_tool_session_mapping(agent_id, tool_name)
+
+ # 清理会话(会被新会话替换)
+ self._session_manager.clear_session(agent_id, service_name)
+
+ self._logger.debug(
+ f"[CLEAR_TOOLS] cleared_tools service={service_name} count={len(tools_to_remove)} keep_mappings=True")
+
+ except Exception as e:
+ self._logger.error(f"[CLEAR_TOOLS] Failed to clear tools {agent_id}:{service_name}: {e}")
+ raise
+
+ def has_tools(self, service_name: str) -> bool:
+ return self._tool_manager.has_tools(service_name)
+
+ # ========================================
+ # 状态管理方法 (委托给StateManager)
+ # 注意:方法签名与原始架构保持一致 (agent_id, service_name)
+ # ========================================
+
+ def get_service_state(self, agent_id: str, service_name: str) -> Optional[Any]:
+ """
+ 获取服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态
+ """
+ return self._run_async(
+ self.get_service_state_async(agent_id, service_name),
+ op_name="ServiceRegistry.get_service_state",
+ )
+
+ def set_service_state(self, agent_id: str, service_name: str, state: Any) -> bool:
+ """
+ 设置服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ state: 服务状态
+
+ Returns:
+ 是否成功
+ """
+ return self._run_async(
+ self.set_service_state_async(agent_id, service_name, state),
+ op_name="ServiceRegistry.set_service_state",
+ )
+
+ async def set_service_state_async(self, agent_id: str, service_name: str, state: Any) -> bool:
+ """
+ 异步设置服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ state: 服务状态
+
+ Returns:
+ 是否成功
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ raise RuntimeError(f"{ERROR_PREFIX} service not found for {agent_id}:{service_name}")
+
+ status = await self._cache_state_manager.get_service_status(global_name)
+ tools_status = []
+ if status and getattr(status, "tools", None):
+ tools_status = [tool.to_dict() for tool in status.tools]
+
+ health_status = state.value if hasattr(state, "value") else str(state)
+ await self._cache_state_manager.update_service_status(
+ service_global_name=global_name,
+ health_status=health_status,
+ tools_status=tools_status,
+ )
+ self._cache_state_snapshot(agent_id, service_name, state)
+ return True
+
+ def get_all_service_states(self, agent_id: str) -> Dict[str, Any]:
+ """
+ 获取指定 Agent 的所有服务状态
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务状态字典
+ """
+ return self._run_async(
+ self.get_all_service_states_async(agent_id),
+ op_name="ServiceRegistry.get_all_service_states",
+ )
+
+ async def get_all_service_states_async(self, agent_id: str) -> Dict[str, Any]:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ result: Dict[str, Any] = {}
+ for svc in services:
+ global_name = svc.get("service_global_name")
+ original_name = svc.get("service_original_name")
+ if not global_name:
+ continue
+ status = await self._cache_state_manager.get_service_status(global_name)
+ if status is None:
+ continue
+ result[original_name or global_name] = self._map_health_status(status.health_status)
+ return result
+
+ def get_services_by_state(self, agent_id: str, states: List[Any]) -> List[str]:
+ """
+ 按状态筛选服务
+
+ Args:
+ agent_id: Agent ID
+ states: 状态列表
+
+ Returns:
+ 服务名称列表
+ """
+ return self._run_async(
+ self.get_services_by_state_async(agent_id, states),
+ op_name="ServiceRegistry.get_services_by_state",
+ )
+
+ async def get_services_by_state_async(self, agent_id: str, states: List[Any]) -> List[str]:
+ target_states = {self._map_health_status(state).value if not isinstance(state, str) else state for state in states}
+ services = await self._relation_manager.get_agent_services(agent_id)
+ matched: List[str] = []
+ for svc in services:
+ global_name = svc.get("service_global_name")
+ original_name = svc.get("service_original_name")
+ if not global_name:
+ continue
+ status = await self._cache_state_manager.get_service_status(global_name)
+ if status and status.health_status in target_states:
+ matched.append(original_name or global_name)
+ return matched
+
+ def clear_service_state(self, agent_id: str, service_name: str) -> bool:
+ """
+ 清除服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 是否成功
+ """
+ return self._run_async(
+ self.delete_service_state_async(agent_id, service_name),
+ op_name="ServiceRegistry.clear_service_state",
+ )
+
+ # [已删除] get_service_metadata 同步方法(重复定义)
+ # 根据 "pykv 唯一真相数据源" 原则,请使用 get_service_metadata_async 异步方法
+
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata: Any) -> bool:
+ """
+ 设置服务元数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务元数据
+
+ Returns:
+ 是否成功
+ """
+ return self._run_async(
+ self.set_service_metadata_async(agent_id, service_name, metadata),
+ op_name="ServiceRegistry.set_service_metadata",
+ )
+
+ async def set_service_metadata_async(self, agent_id: str, service_name: str, metadata: Any) -> bool:
+ """
+ 异步设置服务元数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务元数据
+
+ Returns:
+ 是否成功
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ raise RuntimeError(f"{ERROR_PREFIX} service not found for {agent_id}:{service_name}")
+
+ if metadata is None:
+ return False
+ if isinstance(metadata, ServiceStateMetadata):
+ metadata_obj = metadata
+ metadata_dict = metadata.model_dump(mode="json")
+ elif hasattr(metadata, "model_dump"):
+ metadata_obj = ServiceStateMetadata.model_validate(metadata.model_dump())
+ metadata_dict = metadata.model_dump(mode="json")
+ elif isinstance(metadata, dict):
+ metadata_obj = ServiceStateMetadata.model_validate(metadata)
+ metadata_dict = metadata_obj.model_dump(mode="json")
+ else:
+ raise ValueError("metadata must be a dictionary or ServiceStateMetadata")
+
+ await self._cache_layer_manager.put_state("service_metadata", global_name, metadata_dict)
+ self._cache_metadata_snapshot(agent_id, service_name, metadata_obj)
+ return True
+
+ def get_service_status(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 获取服务状态(legacy 方法)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态字符串
+ """
+ state = self.get_service_state(agent_id, service_name)
+ return state.value if hasattr(state, "value") else state
+
+ def update_service_metadata(self, service_name: str, updates: Dict[str, Any], agent_id: Optional[str] = None) -> bool:
+ self._legacy("update_service_metadata")
+ return False
+
+ def get_service_metadata_timestamp(self, service_name: str, key: str, agent_id: Optional[str] = None) -> Optional[datetime]:
+ self._legacy("get_service_metadata_timestamp")
+
+ def clear_service_metadata(self, service_name: str, keys: Optional[List[str]] = None, agent_id: Optional[str] = None) -> bool:
+ self._legacy("clear_service_metadata")
+ return False
+
+ def get_all_service_metadata(self, service_name: Optional[str] = None, agent_id: Optional[str] = None) -> Dict[str, Any]:
+ self._legacy("get_all_service_metadata")
+ return {}
+
+ def cleanup_old_metadata(self, service_name: Optional[str] = None, agent_id: Optional[str] = None,
+ older_than: Optional[datetime] = None) -> int:
+ self._legacy("cleanup_old_metadata")
+ return 0
+
+ def get_metadata_stats(self) -> Dict[str, Any]:
+ self._legacy("get_metadata_stats")
+ return {}
+
+ def has_metadata(self, service_name: str, agent_id: Optional[str] = None) -> bool:
+ self._legacy("has_metadata")
+ return False
+
+ # ========================================
+ # 缓存管理方法 (委托给CacheManager)
+ # ========================================
+
+ def get_service_names(self) -> List[str]:
+ self._legacy("get_service_names")
+
+ async def get_service_names_async(self) -> List[str]:
+ self._legacy("get_service_names_async")
+
+ def get_agents_for_service(self, service_name: str) -> List[str]:
+ self._legacy("get_agents_for_service")
+
+ async def get_agents_for_service_async(self, service_name: str) -> List[str]:
+ self._legacy("get_agents_for_service_async")
+
+ def clear_cache(self) -> bool:
+ self._legacy("clear_cache")
+
+ def get_stats(self) -> Dict[str, Any]:
+ self._legacy("get_stats")
+
+ # ========================================
+ # 持久化管理方法 (委托给PersistenceManager)
+ # ========================================
+
+ def save_to_file(self, filepath: str) -> bool:
+ self._legacy("save_to_file")
+
+ def load_from_file(self, filepath: str) -> bool:
+ self._legacy("load_from_file")
+
+ async def save_services_async(self, filepath: str) -> bool:
+ self._legacy("save_services_async")
+
+ async def load_services_async(self, filepath: str) -> bool:
+ self._legacy("load_services_async")
+
+ async def save_tools_async(self, filepath: str) -> bool:
+ self._legacy("save_tools_async")
+
+ async def load_tools_async(self, filepath: str) -> bool:
+ self._legacy("load_tools_async")
+
+ def get_last_save_time(self) -> Optional[datetime]:
+ self._legacy("get_last_save_time")
+
+ def get_file_info(self) -> Dict[str, Any]:
+ self._legacy("get_file_info")
+
+ def set_unified_config(self, unified_config: Any) -> None:
+ """
+ 设置统一配置管理器(用于 JSON 配置持久化)
+
+ Args:
+ unified_config: UnifiedConfigManager 实例
+ """
+ if unified_config is None:
+ raise ValueError("unified_config cannot be empty")
+ self._unified_config = unified_config
+
+ # ========================================
+ # legacy 方法
+ # ========================================
+
+ async def load_services_from_json_async(self) -> Dict[str, Any]:
+ """
+ 从 mcp.json 读取服务配置并恢复服务实体
+
+ Returns:
+ 加载结果统计信息
+ """
+ self._legacy("load_services_from_json_async")
+
+ async def delete_service_state_async(self, agent_id: str, service_name: str) -> bool:
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return False
+ await self._cache_layer_manager.delete_state("service_status", global_name)
+ self._cache_state_snapshot(agent_id, service_name, None)
+ return True
+
+ async def delete_service_metadata_async(self, agent_id: str, service_name: str) -> bool:
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return False
+ await self._cache_layer_manager.delete_state("service_metadata", global_name)
+ self._cache_metadata_snapshot(agent_id, service_name, None)
+ return True
+
+ async def get_service_state_async(self, agent_id: str, service_name: str) -> Optional[Any]:
+ """
+ 异步获取服务状态
+
+ 使用缓存层状态管理器(cache/state_manager.py)获取状态。
+ 方法签名:get_service_status(service_global_name)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态或None
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return None
+ status = await self._cache_state_manager.get_service_status(global_name)
+ if status is None:
+ return None
+ health_status = status.health_status if hasattr(status, "health_status") else status.get("health_status")
+ return self._map_health_status(health_status)
+
+ async def get_service_metadata_async(self, agent_id: str, service_name: str) -> Optional[Any]:
+ """
+ 异步获取服务元数据
+
+ 遵循 "pykv 唯一真相数据源" 原则,从 pykv 读取元数据。
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务元数据或None
+ """
+ global_name = await self._resolve_global_name_async(agent_id, service_name)
+ if not global_name:
+ return None
+ metadata = await self._cache_layer_manager.get_state("service_metadata", global_name)
+ if not metadata:
+ self._cache_metadata_snapshot(agent_id, service_name, None)
+ return None
+ metadata_obj = ServiceStateMetadata.model_validate(metadata)
+ self._cache_metadata_snapshot(agent_id, service_name, metadata_obj)
+ return metadata_obj
+
+ def get_service_status(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 获取服务状态(legacy 方法)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态或None
+ """
+ state = self.get_service_state(agent_id, service_name)
+ return state.value if hasattr(state, "value") else state
+
+ # ========================================
+ # legacy 方法 - 使用 (agent_id, service_name) 签名
+ # ========================================
+
+ def set_service_state_v2(self, agent_id: str, service_name: str, state: Optional['ServiceConnectionState']):
+ """
+ 设置服务状态(原始架构签名)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ state: 服务连接状态
+ """
+ self._legacy("set_service_state_v2")
+
+ def set_service_metadata_v2(self, agent_id: str, service_name: str, metadata: Optional['ServiceStateMetadata']):
+ """
+ 设置服务元数据(原始架构签名)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务状态元数据
+ """
+ self._legacy("set_service_metadata_v2")
+
+ # [已删除] get_service_metadata_v2 同步方法
+ # 根据 "pykv 唯一真相数据源" 原则,请使用 get_service_metadata_async 异步方法
+
+ async def set_service_metadata_async_v2(self, agent_id: str, service_name: str, metadata: Optional['ServiceStateMetadata']) -> bool:
+ """
+ 异步设置服务元数据(原始架构签名)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务状态元数据
+
+ Returns:
+ 是否成功
+ """
+ self._legacy("set_service_metadata_async_v2")
+
+ @property
+ def kv_store(self):
+ """获取KV存储实例(legacy 属性)"""
+ raise_legacy_error("ServiceRegistry.kv_store", "Direct kv_store access is disabled.")
+
+ @property
+ def naming(self):
+ """获取命名服务实例(legacy 属性)"""
+ raise_legacy_error("ServiceRegistry.naming", "Direct naming access is disabled.")
+
+ # 新增:支持 unified_sync_manager 的接口
+ async def get_all_entities_for_sync(self, entity_type: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 获取所有实体用于同步
+
+ Args:
+ entity_type: 实体类型 (如 "services")
+
+ Returns:
+ Dict[str, Dict[str, Any]]: 实体数据字典
+ """
+ return await self._cache_layer_manager.get_all_entities_async(entity_type)
+
+ async def get_all_agent_ids_async(self) -> List[str]:
+ """
+ 异步获取所有 Agent ID 列表。
+ """
+ agent_ids: Set[str] = set()
+
+ # 1) 直接从 Agent 实体表获取(即使 Agent 暂无服务也能返回)
+ agents = await self._cache_layer_manager.get_all_entities_async("agents")
+ if isinstance(agents, dict):
+ agent_ids.update(agents.keys())
+
+ # 2) 兼容旧数据:从服务实体中的 source_agent 提取
+ services = await self._cache_layer_manager.get_all_entities_async("services")
+ if isinstance(services, dict):
+ service_agents = {
+ data.get("source_agent")
+ for data in services.values()
+ if isinstance(data, dict) and data.get("source_agent")
+ }
+ agent_ids.update(service_agents)
+
+ # 3) 确保全局 Agent 始终存在
+ agent_ids.add(self._naming.GLOBAL_AGENT_STORE)
+
+ # 过滤 None
+ agent_ids = {a for a in agent_ids if a}
+ return list(agent_ids)
+
+ def get_all_agent_ids(self) -> List[str]:
+ """
+ 获取所有 Agent ID 列表(同步包装)
+ """
+ return self._run_async(
+ self.get_all_agent_ids_async(),
+ op_name="ServiceRegistry.get_all_agent_ids",
+ )
+
+ def get_all_service_names(self, agent_id: str) -> List[str]:
+ """
+ 获取指定 Agent 的所有服务名称
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ List[str]: 服务名称列表
+ """
+ return self._run_async(
+ self.get_services_for_agent_async(agent_id),
+ op_name="ServiceRegistry.get_all_service_names",
+ )
+
+ async def get_all_service_names_async(self, agent_id: str) -> List[str]:
+ """
+ 异步获取指定 Agent 的所有服务名称
+
+ [pykv 唯一真相源] 从 pykv 关系层读取,不从内存缓存读取。
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ List[str]: 服务名称列表
+ """
+ return await self.get_services_for_agent_async(agent_id)
diff --git a/src/mcpstore/core/registry/core_registry/mapping_manager.py b/src/mcpstore/core/registry/core_registry/mapping_manager.py
new file mode 100644
index 00000000..8c3c026e
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/mapping_manager.py
@@ -0,0 +1,435 @@
+"""
+Mapping Manager - 映射管理模块
+
+负责处理各种映射关系,包括:
+1. 服务客户端映射
+2. Agent服务映射
+3. 客户端配置管理
+4. 映射关系的创建、查询和删除
+"""
+
+import logging
+from typing import Dict, Any, Optional, List, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+class MappingManager:
+ """
+ 映射管理器实现
+
+ 职责:
+ - 管理服务与客户端的映射关系
+ - 处理Agent与服务的映射
+ - 管理客户端配置信息
+ - 提供映射关系的查询功能
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ self._cache_layer = cache_layer
+ self._naming = naming_service
+ self._namespace = namespace
+
+ # 映射缓存
+ self._service_client_mapping = {} # agent_id:service_name -> client_id
+ self._agent_service_mapping = {} # agent_id:local_name -> global_name
+ self._client_config = {} # client_id -> config
+
+ # 全局名称反向映射
+ self._global_name_mapping = {} # global_name -> (agent_id, local_name)
+
+ self._logger = logging.getLogger(self.__class__.__name__)
+ self._logger.info(f"[MAPPING_MANAGER] [INIT] Initializing MappingManager, namespace: {namespace}")
+
+ def initialize(self) -> None:
+ """初始化映射管理器"""
+ self._logger.info("[MAPPING_MANAGER] [INIT] MappingManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理映射管理器资源"""
+ try:
+ # 清理所有缓存
+ self._service_client_mapping.clear()
+ self._agent_service_mapping.clear()
+ self._client_config.clear()
+ self._global_name_mapping.clear()
+
+ self._logger.info("[MAPPING_MANAGER] [CLEAN] MappingManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] MappingManager cleanup error: {e}")
+ raise
+
+ async def get_service_client_id_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 异步获取服务客户端ID
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 客户端ID或None
+ """
+ # 异步方法,内部调用同步实现
+ return self.get_service_client_id(agent_id, service_name)
+
+ def get_service_client_id(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 获取服务客户端ID
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 客户端ID或None
+ """
+ try:
+ # 从缓存获取
+ cache_key = f"{agent_id}:{service_name}"
+ return self._service_client_mapping.get(cache_key)
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to get service client ID {agent_id}:{service_name}: {e}")
+ return None
+
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]:
+ """
+ 从 pykv 关系层获取 Agent 的所有客户端
+
+ [pykv 唯一真相源] 所有数据必须从 pykv 读取,不允许绕过。
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 客户端ID列表
+
+ Raises:
+ ValueError: 如果参数无效
+ RuntimeError: 如果获取失败
+ """
+ if not agent_id:
+ raise ValueError("Agent ID cannot be empty")
+
+ # 从 pykv 关系层获取 Agent 的服务列表
+ relation_data = await self._cache_layer.get_relation(
+ "agent_services",
+ agent_id
+ )
+
+ if relation_data is None:
+ self._logger.debug(f"[MAPPING] [INFO] No Agent relationship in pykv: agent_id={agent_id}")
+ return []
+
+ # 提取 client_ids
+ services = relation_data.get("services", [])
+ clients = []
+ for svc in services:
+ client_id = svc.get("client_id")
+ if client_id:
+ clients.append(client_id)
+
+ # 去重
+ unique_clients = list(set(clients))
+
+ self._logger.debug(
+ f"[MAPPING] Getting Agent clients from pykv: agent_id={agent_id}, "
+ f"count={len(unique_clients)}"
+ )
+
+ return unique_clients
+
+ def get_client_config_from_cache(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 从缓存获取客户端配置
+
+ Args:
+ client_id: 客户端ID
+
+ Returns:
+ 客户端配置或None
+ """
+ try:
+ return self._client_config.get(client_id)
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to get client configuration {client_id}: {e}")
+ return None
+
+ def add_client_config(self, client_id: str, config: Dict[str, Any]) -> None:
+ """
+ 添加客户端配置
+
+ Args:
+ client_id: 客户端ID
+ config: 客户端配置
+ """
+ try:
+ self._client_config[client_id] = config
+ self._logger.debug(f"[MAPPING_MANAGER] [ADD] Added client configuration: {client_id}")
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to add client configuration {client_id}: {e}")
+
+ def set_service_client_mapping(self, agent_id: str, service_name: str, client_id: str) -> None:
+ """
+ 设置服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ client_id: 客户端ID
+ """
+ try:
+ cache_key = f"{agent_id}:{service_name}"
+ self._service_client_mapping[cache_key] = client_id
+ self._logger.debug(f"[MAPPING_MANAGER] [SET] Set service client mapping: {cache_key} -> {client_id}")
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to set service client mapping {agent_id}:{service_name}: {e}")
+
+ def remove_service_client_mapping(self, agent_id: str, service_name: str) -> None:
+ """
+ 移除服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ try:
+ cache_key = f"{agent_id}:{service_name}"
+ if cache_key in self._service_client_mapping:
+ del self._service_client_mapping[cache_key]
+ self._logger.debug(f"[MAPPING_MANAGER] [REMOVE] Removed service client mapping: {cache_key}")
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to remove service client mapping {agent_id}:{service_name}: {e}")
+
+ def set_service_client_mapping_async(self, agent_id: str, service_name: str, client_id: str) -> None:
+ """
+ 异步设置服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ client_id: 客户端ID
+ """
+ # 简化实现:同步调用
+ self.set_service_client_mapping(agent_id, service_name, client_id)
+
+ def delete_service_client_mapping_async(self, agent_id: str, service_name: str) -> None:
+ """
+ 异步删除服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ # 简化实现:同步调用
+ self.remove_service_client_mapping(agent_id, service_name)
+
+ def add_agent_service_mapping(self, agent_id: str, local_name: str, global_name: str) -> None:
+ """
+ 添加Agent服务映射
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+ global_name: 全局服务名称
+ """
+ try:
+ cache_key = f"{agent_id}:{local_name}"
+ self._agent_service_mapping[cache_key] = global_name
+ self._global_name_mapping[global_name] = (agent_id, local_name)
+ self._logger.debug(f"[MAPPING_MANAGER] [ADD] Added Agent service mapping: {cache_key} -> {global_name}")
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to add Agent service mapping {agent_id}:{local_name}: {e}")
+
+ def get_global_name_from_agent_service(self, agent_id: str, local_name: str) -> Optional[str]:
+ """
+ 从Agent服务获取全局名称
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+
+ Returns:
+ 全局名称或None
+ """
+ try:
+ cache_key = f"{agent_id}:{local_name}"
+ return self._agent_service_mapping.get(cache_key)
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to get global name {agent_id}:{local_name}: {e}")
+ return None
+
+ def get_global_name_from_agent_service_async(self, agent_id: str, local_name: str) -> Optional[str]:
+ """
+ 异步从Agent服务获取全局名称
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+
+ Returns:
+ 全局名称或None
+ """
+ # 简化实现:同步调用
+ return self.get_global_name_from_agent_service(agent_id, local_name)
+
+ def get_agent_service_from_global_name(self, global_name: str) -> Optional[Tuple[str, str]]:
+ """
+ 从全局名称获取Agent服务
+
+ Args:
+ global_name: 全局名称
+
+ Returns:
+ (agent_id, local_name) 元组或None
+ """
+ try:
+ return self._global_name_mapping.get(global_name)
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to get Agent service {global_name}: {e}")
+ return None
+
+ def get_agent_services(self, agent_id: str) -> List[str]:
+ """
+ 获取Agent的所有服务
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ try:
+ services = []
+ prefix = f"{agent_id}:"
+
+ for cache_key, global_name in self._agent_service_mapping.items():
+ if cache_key.startswith(prefix):
+ local_name = cache_key.split(":", 1)[1]
+ services.append(local_name)
+
+ return services
+
+ except Exception as e:
+ self._logger.error(f"Failed to get Agent service {agent_id}: {e}")
+ return []
+
+ def is_agent_service(self, global_name: str) -> bool:
+ """
+ 检查是否为Agent服务
+
+ Args:
+ global_name: 全局名称
+
+ Returns:
+ 是否为Agent服务
+ """
+ try:
+ return global_name in self._global_name_mapping
+
+ except Exception as e:
+ self._logger.error(f"[MAPPING_MANAGER] [ERROR] Failed to check Agent service {global_name}: {e}")
+ return False
+
+ def remove_agent_service_mapping(self, agent_id: str, local_name: str):
+ """
+ 移除Agent服务映射
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+ """
+ try:
+ cache_key = f"{agent_id}:{local_name}"
+
+ # 获取全局名称
+ global_name = self._agent_service_mapping.get(cache_key)
+
+ # 移除映射
+ if cache_key in self._agent_service_mapping:
+ del self._agent_service_mapping[cache_key]
+
+ # 移除反向映射
+ if global_name and global_name in self._global_name_mapping:
+ del self._global_name_mapping[global_name]
+
+ self._logger.debug(f"Removing Agent service mapping: {cache_key}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to remove Agent service mapping {agent_id}:{local_name}: {e}")
+
+ def clear_agent_mappings(self, agent_id: str):
+ """
+ 清除Agent的所有映射
+
+ Args:
+ agent_id: Agent ID
+ """
+ try:
+ # 移除服务客户端映射
+ prefix = f"{agent_id}:"
+ keys_to_remove = []
+
+ for cache_key in self._service_client_mapping:
+ if cache_key.startswith(prefix):
+ keys_to_remove.append(cache_key)
+
+ for key in keys_to_remove:
+ del self._service_client_mapping[key]
+
+ # 移除Agent服务映射
+ keys_to_remove = []
+ for cache_key in self._agent_service_mapping:
+ if cache_key.startswith(prefix):
+ keys_to_remove.append(cache_key)
+
+ for key in keys_to_remove:
+ global_name = self._agent_service_mapping[key]
+ del self._agent_service_mapping[key]
+
+ # 移除反向映射
+ if global_name in self._global_name_mapping:
+ del self._global_name_mapping[global_name]
+
+ self._logger.info(f"Cleared all Agent mappings: {agent_id}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to clear Agent mappings {agent_id}: {e}")
+
+ def clear_all_mappings(self):
+ """
+ 清除所有映射
+ """
+ try:
+ self._service_client_mapping.clear()
+ self._agent_service_mapping.clear()
+ self._client_config.clear()
+ self._global_name_mapping.clear()
+
+ self._logger.info("Cleared all mappings")
+
+ except Exception as e:
+ self._logger.error(f"Failed to clear all mappings: {e}")
+
+ def get_mapping_stats(self) -> Dict[str, Any]:
+ """
+ 获取映射统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ return {
+ "namespace": self._namespace,
+ "service_client_mappings": len(self._service_client_mapping),
+ "agent_service_mappings": len(self._agent_service_mapping),
+ "clients": len(self._client_config),
+ "global_name_mappings": len(self._global_name_mapping)
+ }
diff --git a/src/mcpstore/core/registry/core_registry/persistence.py b/src/mcpstore/core/registry/core_registry/persistence.py
new file mode 100644
index 00000000..bd1dbaa7
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/persistence.py
@@ -0,0 +1,199 @@
+"""
+Persistence Manager - 持久化管理模块
+
+负责服务配置的JSON文件持久化相关功能,包括:
+1. 从mcp.json加载服务配置
+2. 标准MCP配置字段的提取
+3. 配置数据的解析和验证
+4. 服务实体和关系的创建
+"""
+
+import logging
+from typing import Dict, Any, Optional
+
+from .base import PersistenceManagerInterface
+from .errors import raise_legacy_error
+
+logger = logging.getLogger(__name__)
+
+
+class PersistenceManager(PersistenceManagerInterface):
+ """
+ 持久化管理器实现
+
+ 职责:
+ - 从JSON配置文件加载服务配置
+ - 提取标准MCP配置字段
+ - 处理服务配置的解析和验证
+ - 管理服务实体和关系的创建
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 统一配置管理器(将在后续注入)
+ self._unified_config = None
+
+ # 管理器引用(将在后续注入)
+ self._service_manager = None
+ self._relation_manager = None
+
+ # 标准 MCP 配置字段
+ self._standard_mcp_fields = {
+ 'command', 'args', 'env', 'url',
+ 'transport_type', 'working_dir', 'keep_alive',
+ 'package_name', 'timeout', 'retry_count'
+ }
+
+ self._logger.info(f"Initializing PersistenceManager, namespace: {namespace}")
+
+ def _legacy(self, method: str) -> None:
+ raise_legacy_error(
+ f"core_registry.PersistenceManager.{method}",
+ "Use core/cache managers and shells for persistence workflows.",
+ )
+
+ def initialize(self) -> None:
+ """初始化持久化管理器"""
+ self._logger.info("PersistenceManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理持久化管理器资源"""
+ try:
+ # 清理引用
+ self._unified_config = None
+ self._service_manager = None
+ self._relation_manager = None
+
+ self._logger.info("PersistenceManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"PersistenceManager cleanup error: {e}")
+ raise
+
+ def set_unified_config(self, unified_config: Any) -> None:
+ """
+ 设置统一配置管理器
+
+ Args:
+ unified_config: 统一配置管理器实例
+ """
+ self._legacy("set_unified_config")
+
+ def set_managers(self, service_manager=None, relation_manager=None) -> None:
+ """
+ 设置依赖的管理器
+
+ Args:
+ service_manager: 服务管理器
+ relation_manager: 关系管理器
+ """
+ self._legacy("set_managers")
+
+ def load_services_from_json(self) -> Dict[str, Any]:
+ """
+ 从 mcp.json 读取服务配置并恢复服务实体(同步版本)
+
+ Returns:
+ 加载结果统计信息
+
+ Raises:
+ RuntimeError: 如果 unified_config 未设置
+ """
+ self._legacy("load_services_from_json")
+
+ async def load_services_from_json_async(self) -> Dict[str, Any]:
+ """
+ 从 mcp.json 读取服务配置并恢复服务实体(异步版本)
+
+ Returns:
+ 加载结果统计信息
+
+ Raises:
+ RuntimeError: 如果 unified_config 未设置
+ """
+ self._legacy("load_services_from_json_async")
+
+ def extract_standard_mcp_config(self, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 提取标准的 MCP 配置字段,排除 MCPStore 特定的元数据
+
+ Args:
+ service_config: 完整的服务配置
+
+ Returns:
+ 只包含标准 MCP 字段的配置字典
+
+ Note:
+ 标准 MCP 配置字段包括:
+ - command: 命令
+ - args: 参数列表
+ - env: 环境变量
+ - url: HTTP 服务 URL
+ - transport_type: 传输类型(可选)
+
+ 排除的 MCPStore 特定字段:
+ - added_time: 添加时间
+ - source_agent: 来源 Agent
+ - service_global_name: 全局名称
+ - service_original_name: 原始名称
+ - 其他内部元数据字段
+ """
+ self._legacy("extract_standard_mcp_config")
+
+ def validate_service_config(self, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 验证服务配置的有效性
+
+ Args:
+ service_config: 服务配置
+
+ Returns:
+ 验证结果,包含 is_valid 和 errors 字段
+ """
+ self._legacy("validate_service_config")
+
+ def get_service_config_summary(self, service_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 获取服务配置的摘要信息
+
+ Args:
+ service_config: 服务配置
+
+ Returns:
+ 配置摘要信息
+ """
+ self._legacy("get_service_config_summary")
+
+ def export_service_configs(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 导出服务配置(用于备份或迁移)
+
+ Args:
+ agent_id: 可选的agent_id过滤,如果为None则导出所有
+
+ Returns:
+ 导出的配置数据
+ """
+ self._legacy("export_service_configs")
+
+ def import_service_configs(self, import_data: Dict[str, Any], overwrite: bool = False) -> Dict[str, Any]:
+ """
+ 导入服务配置(用于恢复或迁移)
+
+ Args:
+ import_data: 导入的配置数据
+ overwrite: 是否覆盖已存在的服务
+
+ Returns:
+ 导入结果统计信息
+ """
+ self._legacy("import_service_configs")
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取持久化管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ self._legacy("get_stats")
diff --git a/src/mcpstore/core/registry/core_registry/service_manager.py b/src/mcpstore/core/registry/core_registry/service_manager.py
new file mode 100644
index 00000000..c5596d4a
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/service_manager.py
@@ -0,0 +1,1625 @@
+"""
+Service Manager - 服务管理模块
+
+负责服务的完整生命周期管理,包括:
+1. 服务注册和注销
+2. 服务状态管理
+3. 工具管理和服务关联
+4. 服务配置管理
+5. 长生命周期连接管理
+"""
+
+import logging
+from datetime import datetime
+from typing import Dict, Any, Optional, List, Tuple
+
+from .base import ServiceManagerInterface
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceManager(ServiceManagerInterface):
+ """
+ 服务管理器实现
+
+ 职责:
+ - 管理服务的注册、注销和更新
+ - 处理服务状态和配置
+ - 管理服务与工具的关联
+ - 处理长生命周期连接
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 管理器引用(将在后续注入)
+ self._service_entity_manager = None
+ self._relation_manager = None
+ self._tool_manager = None
+ self._state_manager = None
+ self._session_manager = None
+ self._cache_manager = None
+ self._mapping_manager = None
+
+ # 长生命周期连接标记
+ self.long_lived_connections: set = set()
+
+ # 服务缓存
+ self._service_cache = {}
+
+ self._logger.info(f"[SERVICE_MANAGER] [INIT] Initializing ServiceManager, namespace: {namespace}")
+
+ def initialize(self) -> None:
+ """初始化服务管理器"""
+ self._logger.info("[SERVICE_MANAGER] [INIT] ServiceManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理服务管理器资源"""
+ try:
+ # 清理缓存
+ self._service_cache.clear()
+ self.long_lived_connections.clear()
+
+ # 清理管理器引用
+ self._service_entity_manager = None
+ self._relation_manager = None
+ self._tool_manager = None
+ self._state_manager = None
+ self._session_manager = None
+ self._cache_manager = None
+
+ self._logger.info("[SERVICE_MANAGER] [CLEAN] ServiceManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"[SERVICE_MANAGER] [ERROR] ServiceManager cleanup error: {e}")
+ raise
+
+ def set_managers(self, service_entity_manager=None, relation_manager=None,
+ tool_manager=None, state_manager=None, session_manager=None,
+ cache_manager=None, mapping_manager=None, tool_entity_manager=None):
+ """
+ 设置依赖的管理器
+
+ Args:
+ service_entity_manager: 服务实体管理器
+ relation_manager: 关系管理器
+ tool_manager: 工具管理器
+ state_manager: 状态管理器
+ session_manager: 会话管理器
+ cache_manager: 缓存管理器
+ mapping_manager: 映射管理器
+ tool_entity_manager: 工具实体管理器
+ """
+ self._service_entity_manager = service_entity_manager
+ self._relation_manager = relation_manager
+ self._tool_manager = tool_manager
+ self._state_manager = state_manager
+ self._session_manager = session_manager
+ self._cache_manager = cache_manager
+ self._mapping_manager = mapping_manager
+ self._tool_entity_manager = tool_entity_manager
+ self._logger.info("[SERVICE_MANAGER] [SET] Dependent managers have been set")
+
+ def add_service(self, agent_id: str, name: str, session: Any = None,
+ tools: List[Tuple[str, Dict[str, Any]]] = None,
+ service_config: Dict[str, Any] = None,
+ auto_connect: bool = True) -> bool:
+ """
+ 添加服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+ session: 服务会话对象
+ tools: 工具列表 [(tool_name, tool_def)]
+ service_config: 服务配置
+ auto_connect: 是否自动连接
+
+ Returns:
+ 是否成功添加
+ """
+ try:
+ tools = tools or []
+ service_config = service_config or {}
+
+ # 生成全局名称
+ service_global_name = self._naming.generate_service_global_name(name, agent_id)
+
+ # 确定服务状态
+ if session is not None and len(tools) > 0:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.HEALTHY
+ elif session is not None:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.WARNING
+ else:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.DISCONNECTED
+
+ # 检查服务是否已存在
+ service_exists = False
+ if self._service_entity_manager:
+ existing_service = self._sync_operation(
+ self._service_entity_manager.get_service(service_global_name),
+ f"check_service_exists:{service_global_name}"
+ )
+ if existing_service:
+ logger.debug(f"[SERVICE_MANAGER] [EXISTS] Service already exists: {service_global_name}, will update tools")
+ service_exists = True
+
+ # 创建服务实体(仅当服务不存在时)
+ if not service_exists and self._service_entity_manager:
+ self._sync_operation(
+ self._service_entity_manager.create_service(
+ agent_id=agent_id,
+ original_name=name,
+ config=service_config
+ ),
+ f"create_service:{service_global_name}"
+ )
+
+ # 创建Agent-Service关系(仅当服务不存在时)
+ if not service_exists and self._relation_manager:
+ client_id = f"client_{agent_id}_{name}"
+ self._sync_operation(
+ self._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=name,
+ service_global_name=service_global_name,
+ client_id=client_id
+ ),
+ f"add_agent_service:{agent_id}:{service_global_name}"
+ )
+
+ # 设置服务状态
+ if self._state_manager:
+ self._state_manager.set_service_state(agent_id, name, state)
+
+ # 设置服务会话
+ if self._session_manager and session:
+ self._session_manager.set_session(agent_id, name, session)
+
+ # 设置工具会话映射
+ for tool_name, tool_def in tools:
+ self._session_manager.add_tool_session_mapping(agent_id, tool_name, session)
+
+ # 添加工具
+ self._logger.info(f"[ADD_SERVICE] [CHECK] Checking tool addition conditions: _tool_manager={self._tool_manager is not None}, tools={tools is not None}, tools_count={len(tools) if tools else 0}")
+ if self._tool_manager and tools:
+ self._add_tools_to_service(agent_id, name, tools)
+ else:
+ self._logger.warning(f"[ADD_SERVICE] [SKIP] Skipping tool addition: _tool_manager={self._tool_manager}, tools={tools}")
+
+ # 更新缓存
+ cache_key = f"{agent_id}:{name}"
+ self._service_cache[cache_key] = {
+ "name": name,
+ "global_name": service_global_name,
+ "state": state,
+ "config": service_config,
+ "added_time": datetime.now()
+ }
+
+ self._logger.info(f"[SERVICE_MANAGER] [SUCCESS] Service added successfully: {service_global_name}")
+ return True
+
+ except Exception as e:
+ self._logger.error(f"[SERVICE_MANAGER] [ERROR] Failed to add service {agent_id}:{name}: {e}")
+ return False
+
+ async def add_service_async(self, agent_id: str, name: str, session: Any = None,
+ tools: List[Tuple[str, Dict[str, Any]]] = None,
+ service_config: Dict[str, Any] = None,
+ auto_connect: bool = True) -> bool:
+ """
+ 异步添加服务
+
+ 遵循 "Functional Core, Imperative Shell" 架构原则:
+ - 异步外壳直接使用 await 调用异步操作
+ - 不通过 _sync_operation 转换
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+ session: 服务会话对象
+ tools: 工具列表
+ service_config: 服务配置
+ auto_connect: 是否自动连接
+
+ Returns:
+ 是否成功添加
+ """
+ try:
+ tools = tools or []
+ service_config = service_config or {}
+
+ # 生成全局名称
+ service_global_name = self._naming.generate_service_global_name(name, agent_id)
+
+ # 确定服务状态
+ if session is not None and len(tools) > 0:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.HEALTHY
+ elif session is not None:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.WARNING
+ else:
+ from mcpstore.core.models.service import ServiceConnectionState
+ state = ServiceConnectionState.DISCONNECTED
+
+ # 检查服务是否已存在(异步)
+ service_exists = False
+ if self._service_entity_manager:
+ existing_service = await self._service_entity_manager.get_service(service_global_name)
+ if existing_service:
+ logger.debug(f"[SERVICE_MANAGER] [EXISTS] Service already exists: {service_global_name}, will update tools")
+ service_exists = True
+
+ # 创建服务实体(仅当服务不存在时)
+ if not service_exists and self._service_entity_manager:
+ await self._service_entity_manager.create_service(
+ agent_id=agent_id,
+ original_name=name,
+ config=service_config
+ )
+
+ # 创建Agent-Service关系(仅当服务不存在时)
+ if not service_exists and self._relation_manager:
+ client_id = f"client_{agent_id}_{name}"
+ await self._relation_manager.add_agent_service(
+ agent_id=agent_id,
+ service_original_name=name,
+ service_global_name=service_global_name,
+ client_id=client_id
+ )
+
+ # 设置服务状态(同步操作,使用内存缓存)
+ if self._state_manager:
+ self._state_manager.set_service_state(agent_id, name, state)
+
+ # 设置服务会话(同步操作,使用内存缓存)
+ if self._session_manager and session:
+ self._session_manager.set_session(agent_id, name, session)
+
+ # 设置工具会话映射
+ for tool_name, tool_def in tools:
+ self._session_manager.add_tool_session_mapping(agent_id, tool_name, session)
+
+ # 添加工具(异步)
+ self._logger.info(f"[ADD_SERVICE_ASYNC] [CHECK] Checking tool addition conditions: _tool_manager={self._tool_manager is not None}, tools={tools is not None}, tools_count={len(tools) if tools else 0}")
+ if self._tool_manager and tools:
+ await self._add_tools_to_service_async(agent_id, name, tools)
+ else:
+ self._logger.warning(f"[ADD_SERVICE_ASYNC] Skipping tool addition: _tool_manager={self._tool_manager}, tools={tools}")
+
+ # 更新缓存(同步操作,使用内存缓存)
+ cache_key = f"{agent_id}:{name}"
+ self._service_cache[cache_key] = {
+ "name": name,
+ "global_name": service_global_name,
+ "state": state,
+ "config": service_config,
+ "added_time": datetime.now()
+ }
+
+ self._logger.info(f"[SERVICE_MANAGER] [SUCCESS] Async service addition successful: {service_global_name}")
+ return True
+
+ except Exception as e:
+ self._logger.error(f"[SERVICE_MANAGER] [ERROR] Async service addition failed {agent_id}:{name}: {e}")
+ return False
+
+ def remove_service(self, agent_id: str, name: str) -> Optional[Any]:
+ """
+ 移除服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 被移除的会话对象
+ """
+ try:
+ service_global_name = self._naming.generate_service_global_name(name, agent_id)
+ removed_session = None
+
+ # 获取会话对象
+ if self._session_manager:
+ removed_session = self._session_manager.get_session(agent_id, name)
+
+ # 移除服务实体
+ if self._service_entity_manager:
+ self._sync_operation(
+ self._service_entity_manager.delete_service(service_global_name),
+ f"delete_service:{service_global_name}"
+ )
+
+ # 移除关系
+ if self._relation_manager:
+ self._sync_operation(
+ self._relation_manager.remove_agent_service(agent_id, service_global_name),
+ f"remove_agent_service:{agent_id}:{service_global_name}"
+ )
+
+ # 清理会话
+ if self._session_manager:
+ self._session_manager.clear_session(agent_id, name)
+
+ # 清理状态
+ if self._state_manager:
+ self._state_manager.set_service_state(agent_id, name, None)
+
+ # 清理缓存
+ cache_key = f"{agent_id}:{name}"
+ self._service_cache.pop(cache_key, None)
+
+ # 移除长生命周期标记
+ connection_id = f"{agent_id}:{name}"
+ self.long_lived_connections.discard(connection_id)
+
+ self._logger.info(f"[SERVICE_MANAGER] [SUCCESS] Service removal successful: {service_global_name}")
+ return removed_session
+
+ except Exception as e:
+ self._logger.error(f"[SERVICE_MANAGER] [ERROR] Service removal failed {agent_id}:{name}: {e}")
+ return None
+
+ async def remove_service_async(self, agent_id: str, name: str) -> Optional[Any]:
+ """
+ 异步移除服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 被移除的会话对象
+ """
+ # 简化实现:同步调用
+ return self.remove_service(agent_id, name)
+
+ def replace_service_tools(self, agent_id: str, service_name: str, session: Any,
+ remote_tools: List[Any]) -> Dict[str, Any]:
+ """
+ 替换服务工具
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ session: 服务会话
+ remote_tools: 远程工具列表
+
+ Returns:
+ 替换结果统计
+ """
+ try:
+ service_global_name = self._naming.generate_service_global_name(service_name, agent_id)
+
+ # 清理现有工具映射
+ if self._session_manager:
+ self._session_manager.clear_session(agent_id, service_name)
+
+ # 设置新会话
+ if self._session_manager and session:
+ self._session_manager.set_session(agent_id, service_name, session)
+
+ # 处理远程工具
+ processed_tools = []
+ for remote_tool in remote_tools:
+ if hasattr(remote_tool, 'name') and hasattr(remote_tool, 'schema'):
+ tool_def = {
+ "name": remote_tool.name,
+ "description": getattr(remote_tool, 'description', ''),
+ "inputSchema": remote_tool.schema
+ }
+ processed_tools.append((remote_tool.name, tool_def))
+
+ # 更新工具会话映射
+ if self._session_manager:
+ self._session_manager.add_tool_session_mapping(
+ agent_id, remote_tool.name, session
+ )
+
+ # 添加工具到服务
+ if self._tool_manager and processed_tools:
+ self._add_tools_to_service(agent_id, service_name, processed_tools)
+
+ # 更新服务状态为健康
+ if self._state_manager:
+ from mcpstore.core.models.service import ServiceConnectionState
+ self._state_manager.set_service_state(
+ agent_id, service_name, ServiceConnectionState.HEALTHY
+ )
+
+ result = {
+ "service": service_global_name,
+ "tools_processed": len(processed_tools),
+ "status": "success"
+ }
+
+ self._logger.info(f"[SERVICE_MANAGER] [SUCCESS] Service tools replacement successful: {service_global_name}, tools_count: {len(processed_tools)}")
+ return result
+
+ except Exception as e:
+ self._logger.error(f"[SERVICE_MANAGER] [ERROR] Service tools replacement failed {agent_id}:{service_name}: {e}")
+ return {
+ "service": service_name,
+ "tools_processed": 0,
+ "status": "failed",
+ "error": str(e)
+ }
+
+ async def replace_service_tools_async(self, agent_id: str, service_name: str, session: Any,
+ remote_tools: List[Any]) -> Dict[str, Any]:
+ """
+ 异步替换服务工具
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ session: 服务会话
+ remote_tools: 远程工具列表
+
+ Returns:
+ 替换结果统计
+ """
+ # 简化实现:同步调用
+ return self.replace_service_tools(agent_id, service_name, session, remote_tools)
+
+ def add_failed_service(self, agent_id: str, name: str, service_config: Dict[str, Any],
+ error_info: Optional[Dict[str, Any]] = None) -> bool:
+ """
+ 添加失败的服务
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+ service_config: 服务配置
+ error_info: 错误信息
+
+ Returns:
+ 是否成功添加
+ """
+ try:
+ # 添加服务,但没有会话和工具
+ return self.add_service(
+ agent_id=agent_id,
+ name=name,
+ session=None,
+ tools=[],
+ service_config=service_config,
+ auto_connect=False
+ )
+
+ except Exception as e:
+ self._logger.error(f"Failed to add service {agent_id}:{name}: {e}")
+ return False
+
+ def get_services_for_agent(self, agent_id: str) -> List[str]:
+ """
+ 获取指定agent的所有服务
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ try:
+ # 从关系管理器获取服务
+ if self._relation_manager:
+ services = self._sync_operation(
+ self._relation_manager.get_agent_services(agent_id),
+ f"get_agent_services:{agent_id}"
+ )
+ return [service.get("service_original_name", "") for service in services]
+
+ # 从缓存获取
+ service_names = []
+ cache_prefix = f"{agent_id}:"
+ for cache_key in self._service_cache:
+ if cache_key.startswith(cache_prefix):
+ service_name = cache_key.split(":", 1)[1]
+ service_names.append(service_name)
+
+ return service_names
+
+ except Exception as e:
+ self._logger.error(f"Failed to get agent service list {agent_id}: {e}")
+ return []
+
+ async def get_services_for_agent_async(self, agent_id: str) -> List[str]:
+ """
+ 异步获取指定agent的所有服务
+
+ 遵循 "Functional Core, Imperative Shell" 架构原则:
+ - 异步外壳直接使用 await 调用异步操作
+ - 不通过 _sync_operation 转换
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ try:
+ # 从关系管理器获取服务(异步)
+ if self._relation_manager:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ return [service.get("service_original_name", "") for service in services]
+
+ # 从缓存获取(同步操作,使用内存缓存)
+ service_names = []
+ cache_prefix = f"{agent_id}:"
+ for cache_key in self._service_cache:
+ if cache_key.startswith(cache_prefix):
+ service_name = cache_key.split(":", 1)[1]
+ service_names.append(service_name)
+
+ return service_names
+
+ except Exception as e:
+ self._logger.error(f"Failed to get agent service list asynchronously {agent_id}: {e}")
+ raise
+
+ def get_service_details(self, agent_id: str, name: str) -> Dict[str, Any]:
+ """
+ 获取服务详细信息
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 服务详细信息
+ """
+ try:
+ service_global_name = self._naming.generate_service_global_name(name, agent_id)
+
+ # 获取基础信息
+ cache_key = f"{agent_id}:{name}"
+ cached_info = self._service_cache.get(cache_key, {})
+
+ # 获取服务实体信息
+ service_info = {}
+ if self._service_entity_manager:
+ service_entity = self._sync_operation(
+ self._service_entity_manager.get_service(service_global_name),
+ f"get_service:{service_global_name}"
+ )
+ if service_entity:
+ service_info = {
+ "global_name": service_global_name,
+ "original_name": service_entity.service_original_name,
+ "config": service_entity.config,
+ "added_time": service_entity.added_time
+ }
+
+ # 获取状态信息
+ state = None
+ if self._state_manager:
+ state = self._state_manager.get_service_state(agent_id, name)
+
+ # 获取工具信息
+ tools = []
+ if self._tool_manager:
+ tools = self._tool_manager.get_tools_for_service(agent_id, name)
+
+ # 获取会话信息
+ has_session = False
+ if self._session_manager:
+ has_session = self._session_manager.has_session(agent_id, name)
+
+ # 组合详细信息
+ details = {
+ **cached_info,
+ **service_info,
+ "state": state,
+ "tools": tools,
+ "has_session": has_session,
+ "is_long_lived": self.is_long_lived_service(agent_id, name)
+ }
+
+ return details
+
+ except Exception as e:
+ self._logger.error(f"Failed to get service details {agent_id}:{name}: {e}")
+ return {}
+
+ def get_service_info(self, agent_id: str, service_name: str) -> Optional['ServiceInfo']:
+ """
+ 获取服务信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ ServiceInfo对象或None
+ """
+ try:
+ details = self.get_service_details(agent_id, service_name)
+ if not details:
+ return None
+
+ # 创建ServiceInfo对象
+ return {
+ "name": service_name,
+ "global_name": details.get("global_name"),
+ "state": details.get("state"),
+ "config": details.get("config"),
+ "tools_count": len(details.get("tools", [])),
+ "has_session": details.get("has_session", False),
+ "is_long_lived": details.get("is_long_lived", False)
+ }
+
+ except Exception as e:
+ self._logger.error(f"Failed to get service info {agent_id}:{service_name}: {e}")
+ return None
+
+ def get_service_config(self, agent_id: str, name: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取服务配置
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 服务配置或None
+ """
+ try:
+ # 从缓存获取
+ cache_key = f"{agent_id}:{name}"
+ cached_info = self._service_cache.get(cache_key)
+ if cached_info and "config" in cached_info:
+ return cached_info["config"]
+
+ # 从实体管理器获取
+ if self._service_entity_manager:
+ service_global_name = self._naming.generate_service_global_name(name, agent_id)
+ service_entity = self._sync_operation(
+ self._service_entity_manager.get_service(service_global_name),
+ f"get_service_config:{service_global_name}"
+ )
+ if service_entity:
+ return service_entity.config
+
+ return None
+
+ except Exception as e:
+ self._logger.error(f"Failed to get service config {agent_id}:{name}: {e}")
+ return None
+
+ def mark_as_long_lived(self, agent_id: str, service_name: str):
+ """
+ 标记为长生命周期连接
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ connection_id = f"{agent_id}:{service_name}"
+ self.long_lived_connections.add(connection_id)
+ self._logger.debug(f"Marking long-lived connection: {connection_id}")
+
+ def is_long_lived_service(self, agent_id: str, service_name: str) -> bool:
+ """
+ 检查是否为长生命周期服务
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 是否为长生命周期服务
+ """
+ connection_id = f"{agent_id}:{service_name}"
+ return connection_id in self.long_lived_connections
+
+ def get_long_lived_services(self, agent_id: str) -> List[str]:
+ """
+ 获取长生命周期服务列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 长生命周期服务名称列表
+ """
+ long_lived = []
+ prefix = f"{agent_id}:"
+
+ for connection_id in self.long_lived_connections:
+ if connection_id.startswith(prefix):
+ service_name = connection_id.split(":", 1)[1]
+ long_lived.append(service_name)
+
+ return long_lived
+
+ def remove_service_lifecycle_data(self, agent_id: str, service_name: str):
+ """
+ 移除服务生命周期数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ try:
+ # 移除长生命周期标记
+ connection_id = f"{agent_id}:{service_name}"
+ self.long_lived_connections.discard(connection_id)
+
+ # 清理缓存
+ cache_key = f"{agent_id}:{service_name}"
+ self._service_cache.pop(cache_key, None)
+
+ self._logger.debug(f"Removing service lifecycle data: {connection_id}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to remove service lifecycle data {agent_id}:{service_name}: {e}")
+
+ def clear(self, agent_id: str):
+ """
+ 清除指定agent的所有服务
+
+ Args:
+ agent_id: Agent ID
+ """
+ try:
+ # 获取所有服务
+ services = self.get_services_for_agent(agent_id)
+
+ # 移除所有服务
+ for service_name in services:
+ self.remove_service(agent_id, service_name)
+
+ # 清理长生命周期连接
+ prefix = f"{agent_id}:"
+ to_remove = []
+ for connection_id in self.long_lived_connections:
+ if connection_id.startswith(prefix):
+ to_remove.append(connection_id)
+
+ for connection_id in to_remove:
+ self.long_lived_connections.remove(connection_id)
+
+ # 清理缓存
+ keys_to_remove = []
+ cache_prefix = f"{agent_id}:"
+ for cache_key in self._service_cache:
+ if cache_key.startswith(cache_prefix):
+ keys_to_remove.append(cache_key)
+
+ for key in keys_to_remove:
+ del self._service_cache[key]
+
+ self._logger.info(f"Cleared all agent services: {agent_id}, service count: {len(services)}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to clear agent services {agent_id}: {e}")
+
+ async def clear_async(self, agent_id: str) -> None:
+ """
+ 异步清除指定agent的所有服务
+
+ Args:
+ agent_id: Agent ID
+ """
+ # 简化实现:同步调用
+ self.clear(agent_id)
+
+ def _add_tools_to_service(self, agent_id: str, service_name: str,
+ tools: List[Tuple[str, Dict[str, Any]]]):
+ """
+ 添加工具到服务
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ tools: 工具列表
+ """
+ try:
+ self._logger.info(f"[ADD_TOOLS] Starting to add tools to service: agent={agent_id}, service={service_name}, tools_count={len(tools)}")
+ service_global_name = self._naming.generate_service_global_name(service_name, agent_id)
+
+ for tool_name, tool_def in tools:
+ # 生成工具全局名称
+ # NamingService.generate_tool_global_name 接受 (service_global_name, tool_original_name)
+ tool_global_name = self._naming.generate_tool_global_name(service_global_name, tool_name)
+
+ # 创建工具实体
+ if self._tool_entity_manager:
+ self._sync_operation(
+ self._tool_entity_manager.create_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_original_name=tool_name,
+ tool_def=tool_def
+ ),
+ f"create_tool:{tool_name}"
+ )
+
+ # 创建服务-工具关系
+ # 方法签名:add_service_tool(service_global_name, service_original_name, source_agent, tool_global_name, tool_original_name)
+ if self._relation_manager:
+ self._sync_operation(
+ self._relation_manager.add_service_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_name
+ ),
+ f"add_service_tool:{service_global_name}:{tool_global_name}"
+ )
+
+ except Exception as e:
+ self._logger.error(f"Failed to add tools to service {agent_id}:{service_name}: {e}")
+ raise
+
+ async def _add_tools_to_service_async(self, agent_id: str, service_name: str,
+ tools: List[Tuple[str, Dict[str, Any]]]):
+ """
+ 异步添加工具到服务
+
+ 遵循 "Functional Core, Imperative Shell" 架构原则:
+ - 异步外壳直接使用 await 调用异步操作
+ - 不通过 _sync_operation 转换
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ tools: 工具列表
+ """
+ try:
+ self._logger.info(f"[ADD_TOOLS_ASYNC] Starting to add tools to service asynchronously: agent={agent_id}, service={service_name}, tools_count={len(tools)}")
+ service_global_name = self._naming.generate_service_global_name(service_name, agent_id)
+
+ for tool_name, tool_def in tools:
+ # 生成工具全局名称
+ tool_global_name = self._naming.generate_tool_global_name(service_global_name, tool_name)
+
+ # 创建工具实体(异步)
+ if self._tool_entity_manager:
+ await self._tool_entity_manager.create_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_original_name=tool_name,
+ tool_def=tool_def
+ )
+
+ # 创建服务-工具关系(异步)
+ if self._relation_manager:
+ await self._relation_manager.add_service_tool(
+ service_global_name=service_global_name,
+ service_original_name=service_name,
+ source_agent=agent_id,
+ tool_global_name=tool_global_name,
+ tool_original_name=tool_name
+ )
+
+ except Exception as e:
+ self._logger.error(f"Failed to add tools to service asynchronously {agent_id}:{service_name}: {e}")
+ raise
+
+ def _sync_operation(self, async_coro, operation_name: str = "同步操作"):
+ """
+ 执行同步操作
+
+ Args:
+ async_coro: 异步操作
+ operation_name: 操作名称
+
+ Returns:
+ 异步操作结果
+ """
+ try:
+ if self._cache_manager:
+ # 使用 async_to_sync 方法执行异步协程
+ return self._cache_manager.async_to_sync(async_coro, operation_name)
+ else:
+ # 直接执行异步操作
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # 在新线程中运行
+ import concurrent.futures
+
+ def run_in_thread():
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ try:
+ return new_loop.run_until_complete(async_coro)
+ finally:
+ new_loop.close()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(run_in_thread)
+ return future.result()
+ else:
+ return loop.run_until_complete(async_coro)
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return loop.run_until_complete(async_coro)
+ finally:
+ loop.close()
+
+ except Exception as e:
+ self._logger.error(f"Sync operation failed {operation_name}: {e}")
+ raise
+
+ def get_service_stats(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 获取服务统计信息
+
+ Args:
+ agent_id: 可选的agent_id过滤
+
+ Returns:
+ 统计信息字典
+ """
+ try:
+ if agent_id:
+ # 获取指定agent的统计
+ services = self.get_services_for_agent(agent_id)
+ long_lived_count = len(self.get_long_lived_services(agent_id))
+
+ return {
+ "agent_id": agent_id,
+ "total_services": len(services),
+ "long_lived_services": long_lived_count,
+ "namespace": self._namespace
+ }
+ else:
+ # 获取全局统计
+ all_agents = set()
+ for cache_key in self._service_cache:
+ agent_id = cache_key.split(":", 1)[0]
+ all_agents.add(agent_id)
+
+ return {
+ "total_agents": len(all_agents),
+ "total_services": len(self._service_cache),
+ "long_lived_connections": len(self.long_lived_connections),
+ "namespace": self._namespace
+ }
+
+ except Exception as e:
+ self._logger.error(f"Failed to get service statistics: {e}")
+ return {
+ "error": str(e),
+ "namespace": self._namespace
+ }
+
+ def get_service_summary_async(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 异步获取服务摘要信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务摘要信息
+ """
+ # 简化实现:同步调用
+ return self.get_service_summary(agent_id, service_name)
+
+ def get_service_summary(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 获取服务摘要信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务摘要信息
+ """
+ try:
+ details = self.get_service_details(agent_id, service_name)
+ if not details:
+ return {}
+
+ # 构建摘要信息
+ summary = {
+ "agent_id": agent_id,
+ "service_name": service_name,
+ "global_name": details.get("global_name"),
+ "state": details.get("state"),
+ "has_session": details.get("has_session", False),
+ "tools_count": len(details.get("tools", [])),
+ "is_long_lived": details.get("is_long_lived", False),
+ "config": details.get("config", {}),
+ "last_updated": datetime.now().isoformat()
+ }
+
+ return summary
+
+ except Exception as e:
+ self._logger.error(f"Failed to get service summary {agent_id}:{service_name}: {e}")
+ return {}
+
+ def get_complete_service_info_async(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 异步获取完整服务信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 完整服务信息
+ """
+ # 简化实现:同步调用
+ return self.get_complete_service_info(agent_id, service_name)
+
+ def get_complete_service_info(self, agent_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 获取完整服务信息
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 完整服务信息
+ """
+ try:
+ # 获取基础服务详情
+ details = self.get_service_details(agent_id, service_name)
+ if not details:
+ return {}
+
+ # 获取工具详细信息
+ tools_info = []
+ if self._tool_manager:
+ tools = self._tool_manager.get_tools_for_service(agent_id, service_name)
+ for tool_name in tools:
+ tool_info = self._tool_manager.get_tool_info(agent_id, tool_name)
+ if tool_info:
+ tools_info.append(tool_info)
+
+ # 获取状态信息
+ # 注意:get_complete_service_info 是同步方法,但 get_service_metadata_async 是异步方法
+ # 这里使用内存缓存中的元数据,不从 pykv 读取
+ # 如需从 pykv 读取,请使用 get_complete_service_info_async 异步方法
+ state_info = {}
+ if self._state_manager:
+ state = self._state_manager.get_service_state(agent_id, service_name)
+ # 从内存缓存获取元数据(同步方法中不能调用异步方法)
+ cache_key = f"{agent_id}:{service_name}"
+ metadata = self._state_manager._metadata_cache.get(cache_key)
+ state_info = {
+ "state": state,
+ "metadata": metadata
+ }
+
+ # 获取会话信息
+ session_info = {}
+ if self._session_manager:
+ session = self._session_manager.get_session(agent_id, service_name)
+ session_info = {
+ "has_session": session is not None,
+ "session_type": type(session).__name__ if session else None
+ }
+
+ # 获取摘要信息
+ summary = self.get_service_summary(agent_id, service_name)
+
+ # 构建完整信息
+ complete_info = {
+ **details,
+ "tools": tools_info,
+ "tool_count": len(tools_info), # 添加 tool_count 字段
+ "state_info": state_info,
+ "session_info": session_info,
+ "summary": summary
+ }
+
+ return complete_info
+
+ except Exception as e:
+ self._logger.error(f"Failed to get complete service info {agent_id}:{service_name}: {e}")
+ return {}
+
+ def get_all_services_complete_info(self, agent_id: str) -> List[Dict[str, Any]]:
+ """
+ 获取所有服务的完整信息
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 所有服务的完整信息列表
+ """
+ try:
+ services = self.get_services_for_agent(agent_id)
+ all_info = []
+
+ for service_name in services:
+ complete_info = self.get_complete_service_info(agent_id, service_name)
+ if complete_info:
+ all_info.append(complete_info)
+
+ return all_info
+
+ except Exception as e:
+ self._logger.error(f"Failed to get all services complete info {agent_id}: {e}")
+ return []
+
+ def get_services_by_state(self, agent_id: str, states: List['ServiceConnectionState']) -> List[str]:
+ """
+ 根据状态获取服务列表
+
+ Args:
+ agent_id: Agent ID
+ states: 状态列表
+
+ Returns:
+ 符合状态的服务名称列表
+ """
+ try:
+ if self._state_manager:
+ return self._state_manager.get_services_by_state(agent_id, states)
+ else:
+ # 从缓存获取
+ matching_services = []
+ for service_name in self.get_services_for_agent(agent_id):
+ details = self.get_service_details(agent_id, service_name)
+ if details.get("state") in states:
+ matching_services.append(service_name)
+ return matching_services
+
+ except Exception as e:
+ self._logger.error(f"Failed to get services by status {agent_id}: {e}")
+ return []
+
+ def get_healthy_services(self, agent_id: str) -> List[str]:
+ """
+ 获取健康服务列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 健康服务名称列表
+ """
+ try:
+ from mcpstore.core.models.service import ServiceConnectionState
+ return self.get_services_by_state(agent_id, [ServiceConnectionState.HEALTHY])
+
+ except Exception as e:
+ self._logger.error(f"Failed to get healthy services {agent_id}: {e}")
+ return []
+
+ def get_failed_services(self, agent_id: str) -> List[str]:
+ """
+ 获取失败服务列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 失败服务名称列表
+ """
+ try:
+ from mcpstore.core.models.service import ServiceConnectionState
+ return self.get_services_by_state(agent_id, [
+ ServiceConnectionState.FAILED,
+ ServiceConnectionState.DISCONNECTED
+ ])
+
+ except Exception as e:
+ self._logger.error(f"Failed to get failed services {agent_id}: {e}")
+ return []
+
+ def get_services_with_tools(self, agent_id: str) -> List[str]:
+ """
+ 获取有工具的服务列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 有工具的服务名称列表
+ """
+ try:
+ services_with_tools = []
+ services = self.get_services_for_agent(agent_id)
+
+ for service_name in services:
+ if self._tool_manager:
+ tools = self._tool_manager.get_tools_for_service(agent_id, service_name)
+ if tools:
+ services_with_tools.append(service_name)
+ else:
+ # 从缓存检查
+ details = self.get_service_details(agent_id, service_name)
+ if details.get("tools"):
+ services_with_tools.append(service_name)
+
+ return services_with_tools
+
+ except Exception as e:
+ self._logger.error(f"Failed to get services with tools {agent_id}: {e}")
+ return []
+
+ def should_cache_aggressively(self, agent_id: str, service_name: str) -> bool:
+ """
+ 判断是否应该积极缓存
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 是否应该积极缓存
+ """
+ try:
+ # 检查是否为长生命周期服务
+ if self.is_long_lived_service(agent_id, service_name):
+ return True
+
+ # 检查服务是否有很多工具
+ if self._tool_manager:
+ tools = self._tool_manager.get_tools_for_service(agent_id, service_name)
+ if len(tools) > 5: # 工具数量超过5个
+ return True
+
+ return False
+
+ except Exception as e:
+ self._logger.error(f"Failed to determine cache strategy {agent_id}:{service_name}: {e}")
+ return False
+
+ def remove_service_lifecycle_data(self, agent_id: str, service_name: str):
+ """
+ 移除服务的生命周期数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ try:
+ # 从长生命周期连接中移除
+ connection_id = f"{agent_id}:{service_name}"
+ if connection_id in self.long_lived_connections:
+ self.long_lived_connections.remove(connection_id)
+
+ # 从服务缓存中移除
+ cache_key = f"{agent_id}:{service_name}"
+ if cache_key in self._service_cache:
+ del self._service_cache[cache_key]
+
+ self._logger.debug(f"Removing service lifecycle data: {cache_key}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to remove service lifecycle data {agent_id}:{service_name}: {e}")
+
+ def set_service_lifecycle_data(self, agent_id: str, service_name: str, data: Dict[str, Any]):
+ """
+ 设置服务的生命周期数据
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ data: 生命周期数据
+ """
+ try:
+ cache_key = f"{agent_id}:{service_name}"
+
+ # 确保缓存中存在基础信息
+ if cache_key not in self._service_cache:
+ self._service_cache[cache_key] = {}
+
+ # 更新生命周期数据
+ if "lifecycle_data" not in self._service_cache[cache_key]:
+ self._service_cache[cache_key]["lifecycle_data"] = {}
+
+ self._service_cache[cache_key]["lifecycle_data"].update(data)
+ self._service_cache[cache_key]["lifecycle_data"]["last_updated"] = datetime.now().isoformat()
+
+ self._logger.debug(f"Setting service lifecycle data: {cache_key}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to set service lifecycle data {agent_id}:{service_name}: {e}")
+
+ def clear_agent_lifecycle_data(self, agent_id: str):
+ """
+ 清除agent的所有生命周期数据
+
+ Args:
+ agent_id: Agent ID
+ """
+ try:
+ # 清理所有服务的生命周期数据
+ services = self.get_services_for_agent(agent_id)
+ for service_name in services:
+ self.remove_service_lifecycle_data(agent_id, service_name)
+
+ # 清理长生命周期连接
+ prefix = f"{agent_id}:"
+ to_remove = []
+ for connection_id in self.long_lived_connections:
+ if connection_id.startswith(prefix):
+ to_remove.append(connection_id)
+
+ for connection_id in to_remove:
+ self.long_lived_connections.remove(connection_id)
+
+ self._logger.info(f"Cleared agent lifecycle data: {agent_id}")
+
+ except Exception as e:
+ self._logger.error(f"Failed to clear agent lifecycle data {agent_id}: {e}")
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取服务管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ return {
+ "namespace": self._namespace,
+ "service_cache_size": len(self._service_cache),
+ "long_lived_connections": len(self.long_lived_connections),
+ "has_service_entity_manager": self._service_entity_manager is not None,
+ "has_relation_manager": self._relation_manager is not None,
+ "has_tool_manager": self._tool_manager is not None,
+ "has_state_manager": self._state_manager is not None,
+ "has_session_manager": self._session_manager is not None,
+ "has_cache_manager": self._cache_manager is not None,
+ "has_mapping_manager": self._mapping_manager is not None
+ }
+
+ # ==================== 客户端映射相关方法 ====================
+
+ def get_service_client_id_async(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 异步获取服务客户端ID
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 客户端ID或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_service_client_id_async(agent_id, service_name)
+ return None
+
+ def get_service_client_id(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 获取服务客户端ID
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 客户端ID或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_service_client_id(agent_id, service_name)
+ return None
+
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]:
+ """
+ 从 pykv 关系层获取 Agent 的所有客户端
+
+ [pykv 唯一真相源] 所有数据必须从 pykv 读取
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 客户端ID列表
+
+ Raises:
+ RuntimeError: 如果 mapping_manager 未初始化
+ """
+ if not self._mapping_manager:
+ raise RuntimeError("MappingManager not initialized")
+ return await self._mapping_manager.get_agent_clients_async(agent_id)
+
+ def get_client_config_from_cache(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 从缓存获取客户端配置
+
+ Args:
+ client_id: 客户端ID
+
+ Returns:
+ 客户端配置或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_client_config_from_cache(client_id)
+ return None
+
+ def add_client_config(self, client_id: str, config: Dict[str, Any]) -> None:
+ """
+ 添加客户端配置
+
+ Args:
+ client_id: 客户端ID
+ config: 客户端配置
+ """
+ if self._mapping_manager:
+ self._mapping_manager.add_client_config(client_id, config)
+
+ def set_service_client_mapping(self, agent_id: str, service_name: str, client_id: str) -> None:
+ """
+ 设置服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ client_id: 客户端ID
+ """
+ if self._mapping_manager:
+ self._mapping_manager.set_service_client_mapping(agent_id, service_name, client_id)
+
+ def remove_service_client_mapping(self, agent_id: str, service_name: str) -> None:
+ """
+ 移除服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ if self._mapping_manager:
+ self._mapping_manager.remove_service_client_mapping(agent_id, service_name)
+
+ def set_service_client_mapping_async(self, agent_id: str, service_name: str, client_id: str) -> None:
+ """
+ 异步设置服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ client_id: 客户端ID
+ """
+ if self._mapping_manager:
+ self._mapping_manager.set_service_client_mapping_async(agent_id, service_name, client_id)
+
+ def delete_service_client_mapping_async(self, agent_id: str, service_name: str) -> None:
+ """
+ 异步删除服务客户端映射
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ if self._mapping_manager:
+ self._mapping_manager.delete_service_client_mapping_async(agent_id, service_name)
+
+ def add_agent_service_mapping(self, agent_id: str, local_name: str, global_name: str) -> None:
+ """
+ 添加Agent服务映射
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+ global_name: 全局服务名称
+ """
+ if self._mapping_manager:
+ self._mapping_manager.add_agent_service_mapping(agent_id, local_name, global_name)
+
+ def get_global_name_from_agent_service(self, agent_id: str, local_name: str) -> Optional[str]:
+ """
+ 从Agent服务获取全局名称
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+
+ Returns:
+ 全局名称或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_global_name_from_agent_service(agent_id, local_name)
+ return None
+
+ def get_global_name_from_agent_service_async(self, agent_id: str, local_name: str) -> Optional[str]:
+ """
+ 异步从Agent服务获取全局名称
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+
+ Returns:
+ 全局名称或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_global_name_from_agent_service_async(agent_id, local_name)
+ return None
+
+ def get_agent_service_from_global_name(self, global_name: str) -> Optional[Tuple[str, str]]:
+ """
+ 从全局名称获取Agent服务
+
+ Args:
+ global_name: 全局名称
+
+ Returns:
+ (agent_id, local_name) 元组或None
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_agent_service_from_global_name(global_name)
+ return None
+
+ def get_agent_services(self, agent_id: str) -> List[str]:
+ """
+ 获取Agent的所有服务
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_agent_services(agent_id)
+ return []
+
+ def is_agent_service(self, global_name: str) -> bool:
+ """
+ 检查是否为Agent服务
+
+ Args:
+ global_name: 全局名称
+
+ Returns:
+ 是否为Agent服务
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.is_agent_service(global_name)
+ return False
+
+ def remove_agent_service_mapping(self, agent_id: str, local_name: str) -> None:
+ """
+ 移除Agent服务映射
+
+ Args:
+ agent_id: Agent ID
+ local_name: 本地服务名称
+ """
+ if self._mapping_manager:
+ self._mapping_manager.remove_agent_service_mapping(agent_id, local_name)
+
+ def clear_agent_mappings(self, agent_id: str) -> None:
+ """
+ 清除Agent的所有映射
+
+ Args:
+ agent_id: Agent ID
+ """
+ if self._mapping_manager:
+ self._mapping_manager.clear_agent_mappings(agent_id)
+
+ def clear_all_mappings(self) -> None:
+ """
+ 清除所有映射
+ """
+ if self._mapping_manager:
+ self._mapping_manager.clear_all_mappings()
+
+ def get_mapping_stats(self) -> Dict[str, Any]:
+ """
+ 获取映射统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ if self._mapping_manager:
+ return self._mapping_manager.get_mapping_stats()
+ return {}
diff --git a/src/mcpstore/core/registry/core_registry/session_manager.py b/src/mcpstore/core/registry/core_registry/session_manager.py
new file mode 100644
index 00000000..ba28f959
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/session_manager.py
@@ -0,0 +1,314 @@
+"""
+Session Manager - 会话管理模块
+
+负责管理MCP服务的会话对象,包括:
+1. 服务会话的存储和检索
+2. 工具到会话的映射管理
+3. 会话的生命周期管理
+4. 会话数据的内存隔离
+
+注意:会话数据总是存储在内存中,因为MCP Session对象不可序列化。
+"""
+
+import logging
+from typing import Dict, Any, Optional, List
+
+from .base import SessionManagerInterface
+
+logger = logging.getLogger(__name__)
+
+
+class SessionManager(SessionManagerInterface):
+ """
+ 会话管理器实现
+
+ 职责:
+ - 管理服务会话对象(内存存储)
+ - 维护工具到会话的映射关系
+ - 提供会话的增删改查操作
+ - 确保会话数据的agent隔离
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 服务会话存储 - agent_id: {service_name: session}
+ # 注意:会话数据总是存储在内存中,因为MCP Session对象不可序列化
+ self.sessions: Dict[str, Dict[str, Any]] = {}
+
+ # 工具到会话的映射 - agent_id: {tool_name: session}
+ self.tool_to_session_map: Dict[str, Dict[str, Any]] = {}
+
+ self._logger.info(f"[SESSION_MANAGER] [INIT] Initializing SessionManager, namespace: {namespace}")
+
+ def initialize(self) -> None:
+ """初始化会话管理器"""
+ self._logger.info("[SESSION_MANAGER] [INIT] SessionManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理会话管理器资源"""
+ try:
+ # 清理所有会话数据
+ session_count = sum(len(services) for services in self.sessions.values())
+ tool_mapping_count = sum(len(tools) for tools in self.tool_to_session_map.values())
+
+ self.sessions.clear()
+ self.tool_to_session_map.clear()
+
+ self._logger.info(f"[SESSION_MANAGER] [CLEAN] SessionManager cleanup completed: cleared {session_count} service sessions, {tool_mapping_count} tool mappings")
+ except Exception as e:
+ self._logger.error(f"[SESSION_MANAGER] [ERROR] SessionManager cleanup error: {e}")
+ raise
+
+ def get_session(self, agent_id: str, name: str) -> Optional[Any]:
+ """
+ 获取指定agent_id下服务的会话对象(同步,仅内存)
+
+ Args:
+ agent_id: Agent ID
+ name: 服务名称
+
+ Returns:
+ 会话对象或None
+
+ Note:
+ 会话数据总是存储在内存中,不会持久化到py-key-value存储,
+ 因为MCP Session对象不可序列化。
+ 这是同步方法且保持同步。
+ """
+ session = self.sessions.get(agent_id, {}).get(name)
+ self._logger.debug(f"[SESSION_MANAGER] [GET] Got session: agent={agent_id}, service={name}, found={session is not None}")
+ return session
+
+ def set_session(self, agent_id: str, service_name: str, session: Any) -> None:
+ """
+ 设置指定agent_id下服务的会话对象(同步,仅内存)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ session: 要存储的会话对象
+
+ Note:
+ 会话数据总是存储在内存中,不会持久化到py-key-value存储,
+ 因为MCP Session对象不可序列化。
+ 此方法包含防御性检查以防止意外的序列化。
+
+ Raises:
+ SessionSerializationError: 如果会话包含不可序列化的引用
+ """
+ # 导入异常映射器进行验证
+ from ..exception_mapper import validate_session_serializable
+
+ # 防御性检查:验证会话不包含不可序列化的引用
+ validate_session_serializable(session, agent_id, service_name)
+
+ # 存储到内存
+ if agent_id not in self.sessions:
+ self.sessions[agent_id] = {}
+ self.sessions[agent_id][service_name] = session
+
+ self._logger.debug(f"[SESSION_MANAGER] [SET] Set session: agent={agent_id}, service={service_name}")
+
+ def get_session_for_tool(self, agent_id: str, tool_name: str) -> Optional[Any]:
+ """
+ 获取指定agent_id下工具对应的服务会话
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+
+ Returns:
+ 工具对应的会话对象或None
+ """
+ session = self.tool_to_session_map.get(agent_id, {}).get(tool_name)
+ self._logger.debug(f"[SESSION_MANAGER] [GET] Got tool session: agent={agent_id}, tool={tool_name}, found={session is not None}")
+ return session
+
+ def clear_session(self, agent_id: str, service_name: str):
+ """
+ 清除特定服务的会话
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ """
+ removed_session = None
+
+ # 从服务会话中移除
+ if agent_id in self.sessions and service_name in self.sessions[agent_id]:
+ removed_session = self.sessions[agent_id].pop(service_name)
+
+ # 从工具映射中移除相关的工具会话
+ if agent_id in self.tool_to_session_map:
+ tools_to_remove = []
+ for tool_name, tool_session in self.tool_to_session_map[agent_id].items():
+ if tool_session is removed_session:
+ tools_to_remove.append(tool_name)
+
+ for tool_name in tools_to_remove:
+ del self.tool_to_session_map[agent_id][tool_name]
+
+ self._logger.debug(f"[SESSION_MANAGER] [CLEAR] Cleared session: agent={agent_id}, service={service_name}, removed={removed_session is not None}")
+
+ def clear_all_sessions(self, agent_id: str):
+ """
+ 清除指定agent_id的所有会话
+
+ Args:
+ agent_id: Agent ID
+ """
+ session_count = len(self.sessions.get(agent_id, {}))
+ tool_mapping_count = len(self.tool_to_session_map.get(agent_id, {}))
+
+ # 清除服务会话
+ self.sessions.pop(agent_id, None)
+
+ # 清除工具映射
+ self.tool_to_session_map.pop(agent_id, None)
+
+ self._logger.info(f"[SESSION_MANAGER] [CLEAR] Cleared all sessions: agent={agent_id}, services={session_count}, tools={tool_mapping_count}")
+
+ def add_tool_session_mapping(self, agent_id: str, tool_name: str, session: Any) -> None:
+ """
+ 添加工具到会话的映射关系
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+ session: 会话对象
+ """
+ if agent_id not in self.tool_to_session_map:
+ self.tool_to_session_map[agent_id] = {}
+ self.tool_to_session_map[agent_id][tool_name] = session
+
+ self._logger.debug(f"[SESSION_MANAGER] [ADD] Added tool session mapping: agent={agent_id}, tool={tool_name}")
+
+ def remove_tool_session_mapping(self, agent_id: str, tool_name: str) -> Optional[Any]:
+ """
+ 移除工具到会话的映射关系
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+
+ Returns:
+ 被移除的会话对象
+ """
+ removed_session = None
+
+ if agent_id in self.tool_to_session_map and tool_name in self.tool_to_session_map[agent_id]:
+ removed_session = self.tool_to_session_map[agent_id].pop(tool_name)
+
+ self._logger.debug(f"[SESSION_MANAGER] [REMOVE] Removed tool session mapping: agent={agent_id}, tool={tool_name}, removed={removed_session is not None}")
+ return removed_session
+
+ def get_all_service_names(self, agent_id: str) -> List[str]:
+ """
+ 获取指定agent_id下所有有会话的服务名称
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称列表
+ """
+ return list(self.sessions.get(agent_id, {}).keys())
+
+ def get_all_tool_names(self, agent_id: str) -> List[str]:
+ """
+ 获取指定agent_id下所有有会话映射的工具名称
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 工具名称列表
+ """
+ return list(self.tool_to_session_map.get(agent_id, {}).keys())
+
+ def has_session(self, agent_id: str, service_name: str) -> bool:
+ """
+ 检查指定agent_id下服务是否有会话
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 是否存在会话
+ """
+ return service_name in self.sessions.get(agent_id, {})
+
+ def has_tool_session_mapping(self, agent_id: str, tool_name: str) -> bool:
+ """
+ 检查指定agent_id下工具是否有会话映射
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+
+ Returns:
+ 是否存在会话映射
+ """
+ return tool_name in self.tool_to_session_map.get(agent_id, {})
+
+ def get_session_count(self, agent_id: str) -> int:
+ """
+ 获取指定agent_id的会话数量
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 会话数量
+ """
+ return len(self.sessions.get(agent_id, {}))
+
+ def get_tool_mapping_count(self, agent_id: str) -> int:
+ """
+ 获取指定agent_id的工具映射数量
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 工具映射数量
+ """
+ return len(self.tool_to_session_map.get(agent_id, {}))
+
+ def get_agent_ids_with_sessions(self) -> List[str]:
+ """
+ 获取所有有会话的agent_id列表
+
+ Returns:
+ agent_id列表
+ """
+ return list(self.sessions.keys())
+
+ def get_agent_ids_with_tool_mappings(self) -> List[str]:
+ """
+ 获取所有有工具映射的agent_id列表
+
+ Returns:
+ agent_id列表
+ """
+ return list(self.tool_to_session_map.keys())
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取会话管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ total_sessions = sum(len(services) for services in self.sessions.values())
+ total_tool_mappings = sum(len(tools) for tools in self.tool_to_session_map.values())
+
+ return {
+ "total_sessions": total_sessions,
+ "total_tool_mappings": total_tool_mappings,
+ "agents_with_sessions": len(self.sessions),
+ "agents_with_tool_mappings": len(self.tool_to_session_map),
+ "namespace": self._namespace
+ }
diff --git a/src/mcpstore/core/registry/core_registry/state_manager.py b/src/mcpstore/core/registry/core_registry/state_manager.py
new file mode 100644
index 00000000..b5c89310
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/state_manager.py
@@ -0,0 +1,325 @@
+"""
+State Manager - 状态管理模块
+
+负责服务和工具状态的管理,包括:
+1. 服务连接状态的设置和查询
+2. 服务元数据的管理
+3. 状态同步机制
+4. 异步到同步操作的转换
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any, Optional, List
+
+from .base import StateManagerInterface
+from .errors import raise_legacy_error
+
+logger = logging.getLogger(__name__)
+
+
+class StateManager(StateManagerInterface):
+ """
+ 状态管理器实现
+
+ 职责:
+ - 管理服务的连接状态
+ - 处理服务的元数据
+ - 提供状态同步机制
+ - 处理异步到同步的转换
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 状态同步管理器(懒加载)
+ self._state_sync_manager = None
+
+ # 同步助手(懒加载)
+ self._sync_helper = None
+
+ # 状态缓存
+ self._state_cache = {}
+
+ # 元数据缓存
+ self._metadata_cache = {}
+
+ # CacheLayerManager 实例(用于 pykv 操作)
+ # 必须通过 set_cache_layer_manager() 方法设置
+ self._cache_layer_manager = None
+
+ self._logger.info(f"Initializing StateManager, namespace: {namespace}")
+
+ def _legacy(self, method: str) -> None:
+ raise_legacy_error(
+ f"core_registry.StateManager.{method}",
+ "Use mcpstore.core.cache.state_manager.StateManager via CacheLayerManager.",
+ )
+
+ def set_cache_layer_manager(self, cache_layer_manager) -> None:
+ """
+ 设置 CacheLayerManager 实例
+
+ StateManager 需要 CacheLayerManager 来执行 pykv 操作(如 get_state)。
+ 这个方法必须在使用 get_service_metadata_async 之前调用。
+
+ Args:
+ cache_layer_manager: CacheLayerManager 实例
+ """
+ self._cache_layer_manager = cache_layer_manager
+ self._logger.debug("CacheLayerManager has been set")
+
+ def initialize(self) -> None:
+ """初始化状态管理器"""
+ self._logger.info("StateManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理状态管理器资源"""
+ try:
+ # 清理缓存
+ self._state_cache.clear()
+ self._metadata_cache.clear()
+
+ # 清理管理器
+ if self._state_sync_manager:
+ self._state_sync_manager = None
+
+ self._sync_helper = None
+
+ self._logger.info("StateManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"StateManager cleanup error: {e}")
+ raise
+
+ def set_service_state(self, agent_id: str, service_name: str, state: Optional['ServiceConnectionState']):
+ """
+ 设置服务状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ state: 服务连接状态
+
+ Note:
+ 使用内存缓存存储状态,不直接操作 pykv。
+ pykv 状态由 cache/state_manager.py 管理。
+ """
+ self._legacy("set_service_state")
+
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata: Optional['ServiceStateMetadata']):
+ """
+ 设置服务元数据(同步版本)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ metadata: 服务状态元数据
+
+ Note:
+ 使用 CacheLayerManager 的同步方法写入 pykv
+ """
+ self._legacy("set_service_metadata")
+
+ def get_all_service_states(self, agent_id: str) -> Dict[str, 'ServiceConnectionState']:
+ """
+ 获取指定agent_id的所有服务状态
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称到状态的映射
+ """
+ self._legacy("get_all_service_states")
+
+ async def get_all_service_states_async(self, agent_id: str) -> Dict[str, 'ServiceConnectionState']:
+ """
+ 异步获取指定agent_id的所有服务状态
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 服务名称到状态的映射
+ """
+ self._legacy("get_all_service_states_async")
+
+ def get_connected_services(self, agent_id: str) -> List[Dict[str, Any]]:
+ """
+ 获取已连接的服务列表
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 已连接服务的信息列表
+ """
+ self._legacy("get_connected_services")
+
+ def get_service_state(self, agent_id: str, service_name: str) -> Optional['ServiceConnectionState']:
+ """
+ 获取指定服务的状态
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态或None
+ """
+ self._legacy("get_service_state")
+
+ # [已删除] get_service_metadata 同步方法
+ # 根据 "pykv 唯一真相数据源" 原则,所有元数据读取必须从 pykv 获取
+ # 请使用 get_service_metadata_async 异步方法
+
+ def sync_to_storage(self, operation, operation_name: str = "状态同步"):
+ """
+ 同步执行异步操作
+
+ Args:
+ operation: 异步操作
+ operation_name: 操作名称
+
+ Returns:
+ 异步操作的结果
+ """
+ self._legacy("sync_to_storage")
+
+ def _ensure_state_sync_manager(self):
+ """
+ 确保状态同步管理器存在(懒加载)
+ """
+ self._legacy("_ensure_state_sync_manager")
+
+ def _ensure_sync_helper(self):
+ """
+ 确保同步助手存在(懒加载)
+ """
+ self._legacy("_ensure_sync_helper")
+
+ async def _get_all_service_states_async_operation(self, agent_id: str) -> Dict[str, 'ServiceConnectionState']:
+ """异步获取所有服务状态操作的包装"""
+ self._legacy("_get_all_service_states_async_operation")
+
+ def clear_agent_states(self, agent_id: str):
+ """
+ 清除指定agent_id的所有状态缓存
+
+ Args:
+ agent_id: Agent ID
+ """
+ self._legacy("clear_agent_states")
+
+ def get_services_by_state(self, agent_id: str, states: List['ServiceConnectionState']) -> List[str]:
+ """
+ 根据状态获取服务列表
+
+ Args:
+ agent_id: Agent ID
+ states: 状态列表
+
+ Returns:
+ 符合条件的服务名称列表
+ """
+ self._legacy("get_services_by_state")
+
+ def get_state_stats(self, agent_id: Optional[str] = None) -> Dict[str, Any]:
+ """
+ 获取状态统计信息
+
+ Args:
+ agent_id: 可选的agent_id过滤
+
+ Returns:
+ 状态统计信息
+ """
+ self._legacy("get_state_stats")
+
+ async def get_service_metadata_async(self, agent_id: str, service_name: str) -> Optional['ServiceStateMetadata']:
+ """
+ 异步获取服务元数据
+
+ 遵循 "pykv 唯一真相数据源" 原则,直接从 pykv 读取元数据。
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态元数据或None
+
+ Raises:
+ RuntimeError: 如果 CacheLayerManager 未设置
+ """
+ self._legacy("get_service_metadata_async")
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取状态管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ self._legacy("get_stats")
+
+ def get_service_status(self, agent_id: str, service_name: str) -> Optional[str]:
+ """
+ 获取服务状态(兼容性方法)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务状态或None
+ """
+ self._legacy("get_service_status")
+
+
+class AsyncSyncHelper:
+ """异步同步助手,用于在同步环境中运行异步操作"""
+
+ def __init__(self):
+ self._loop = None
+
+ def run_sync(self, coro):
+ """
+ 在同步环境中运行异步协程
+
+ Args:
+ coro: 异步协程
+
+ Returns:
+ 异步操作的结果
+ """
+ try:
+ # 尝试获取当前事件循环
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # 如果事件循环正在运行,我们需要在新线程中运行
+ import concurrent.futures
+ import threading
+
+ def run_in_thread():
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ try:
+ return new_loop.run_until_complete(coro)
+ finally:
+ new_loop.close()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(run_in_thread)
+ return future.result()
+ else:
+ # 如果事件循环没有运行,直接运行
+ return loop.run_until_complete(coro)
+ except RuntimeError:
+ # 没有事件循环,创建一个新的
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
diff --git a/src/mcpstore/core/registry/core_registry/tool_manager.py b/src/mcpstore/core/registry/core_registry/tool_manager.py
new file mode 100644
index 00000000..39d359bf
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/tool_manager.py
@@ -0,0 +1,692 @@
+"""
+Tool Manager - 工具管理模块
+
+负责工具信息的管理和处理,包括:
+1. 工具定义的获取和查询
+2. 工具信息的格式化和处理
+3. JSON Schema 解析和类型推断
+4. 工具与服务的关联管理
+"""
+
+import logging
+from typing import Dict, Any, Optional, List
+
+from .base import ToolManagerInterface
+from .utils import JSONSchemaUtils
+
+logger = logging.getLogger(__name__)
+
+
+class ToolManager(ToolManagerInterface):
+ """
+ 工具管理器实现
+
+ 职责:
+ - 管理工具定义和信息
+ - 处理工具与服务的关联
+ - 提供工具查询和过滤功能
+ - 处理 JSON Schema 相关逻辑
+ """
+
+ def __init__(self, cache_layer, naming_service, namespace: str = "default"):
+ super().__init__(cache_layer, naming_service, namespace)
+
+ # 管理器引用(将在后续注入)
+ self._relation_manager = None
+ self._tool_entity_manager = None
+ self._cache_manager = None
+
+ # 工具缓存
+ self._tool_cache = {}
+
+ # Schema 处理工具
+ self._schema_utils = JSONSchemaUtils()
+
+ self._logger.info(f"[TOOL_MANAGER] [INIT] Initializing ToolManager, namespace: {namespace}")
+
+ def initialize(self) -> None:
+ """初始化工具管理器"""
+ self._logger.info("[TOOL_MANAGER] [INIT] ToolManager initialization completed")
+
+ def cleanup(self) -> None:
+ """清理工具管理器资源"""
+ try:
+ # 清理缓存
+ self._tool_cache.clear()
+
+ # 清理管理器引用
+ self._relation_manager = None
+ self._tool_entity_manager = None
+ self._cache_manager = None
+
+ self._logger.info("[TOOL_MANAGER] [CLEAN] ToolManager cleanup completed")
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] ToolManager cleanup error: {e}")
+ raise
+
+ def set_managers(self, relation_manager=None, tool_entity_manager=None, cache_manager=None):
+ """
+ 设置依赖的管理器
+
+ Args:
+ relation_manager: 关系管理器
+ tool_entity_manager: 工具实体管理器
+ cache_manager: 缓存管理器
+ """
+ self._relation_manager = relation_manager
+ self._tool_entity_manager = tool_entity_manager
+ self._cache_manager = cache_manager
+ self._logger.info("[TOOL_MANAGER] [SET] Dependent managers have been set")
+
+ def get_all_tools(self, agent_id: str) -> List[Dict[str, Any]]:
+ """
+ 获取指定agent_id下的所有工具定义
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 工具定义列表
+ """
+ try:
+ # 使用缓存管理器执行同步操作
+ if self._cache_manager:
+ tools_dict = self._cache_manager.async_to_sync(
+ self.get_all_tools_dict_async(agent_id),
+ f"list_tools:{agent_id}"
+ )
+ else:
+ # 直接执行异步操作
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # 在新线程中运行
+ import concurrent.futures
+ import threading
+
+ def run_in_thread():
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ try:
+ return new_loop.run_until_complete(
+ self.get_all_tools_dict_async(agent_id)
+ )
+ finally:
+ new_loop.close()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(run_in_thread)
+ tools_dict = future.result()
+ else:
+ tools_dict = loop.run_until_complete(
+ self.get_all_tools_dict_async(agent_id)
+ )
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ tools_dict = loop.run_until_complete(
+ self.get_all_tools_dict_async(agent_id)
+ )
+ finally:
+ loop.close()
+
+ # 转换为列表格式
+ # 注意:tool_name 是 tool_global_name,tool_def 中的 "name" 是原始名称
+ # 需要确保 "name" 字段使用 tool_global_name
+ tools_list = []
+ for tool_global_name, tool_def in tools_dict.items():
+ tool_entry = dict(tool_def) # 复制一份,避免修改原始数据
+ tool_entry["name"] = tool_global_name # 确保使用全局名称
+ tool_entry["tool_global_name"] = tool_global_name # 添加全局名称字段
+ tools_list.append(tool_entry)
+
+ self._logger.debug(f"[TOOL_MANAGER] [GET] Got all tools: agent={agent_id}, count={len(tools_list)}")
+ return tools_list
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to get all tools {agent_id}: {e}")
+ return []
+
+ async def get_all_tools_dict_async(self, agent_id: str) -> Dict[str, Dict[str, Any]]:
+ """
+ 从三层缓存架构获取指定Agent的所有工具(异步版本)
+
+ 使用新的缓存架构:
+ 1. 从关系层获取Agent的所有服务
+ 2. 从关系层获取每个服务的工具列表
+ 3. 从实体层批量获取工具定义
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 工具字典 {tool_global_name: tool_definition}
+ """
+ tools_dict: Dict[str, Dict[str, Any]] = {}
+
+ try:
+ # 1. 获取Agent的所有服务关系
+ if self._relation_manager:
+ services = await self._relation_manager.get_agent_services(agent_id)
+ else:
+ # 从缓存层直接获取
+ services = []
+
+ if not services:
+ self._logger.debug(f"[TOOL_MANAGER] [INFO] Agent {agent_id} has no services")
+ return tools_dict
+
+ # 2. 收集所有工具全局名称
+ all_tool_global_names = []
+ for service in services:
+ service_global_name = service.get("service_global_name")
+ if not service_global_name:
+ continue
+
+ # 获取服务的工具关系
+ if self._relation_manager:
+ tool_relations = await self._relation_manager.get_service_tools(
+ service_global_name
+ )
+
+ for tool_rel in tool_relations:
+ tool_global_name = tool_rel.get("tool_global_name")
+ if tool_global_name:
+ all_tool_global_names.append(tool_global_name)
+
+ if not all_tool_global_names:
+ self._logger.debug(f"[TOOL_MANAGER] [INFO] Agent {agent_id} has no tools")
+ return tools_dict
+
+ # 3. 批量获取工具实体
+ if self._tool_entity_manager:
+ tool_entities = await self._tool_entity_manager.get_many_tools(
+ all_tool_global_names
+ )
+ else:
+ # 从缓存层获取
+ tool_entities = []
+
+ # 4. 构建工具字典
+ for i, entity in enumerate(tool_entities):
+ if entity is None:
+ continue
+
+ tool_global_name = all_tool_global_names[i]
+
+ # 转换为标准格式
+ tools_dict[tool_global_name] = {
+ "name": entity.tool_original_name,
+ "display_name": entity.tool_original_name,
+ "original_name": entity.tool_original_name,
+ "description": entity.description,
+ "inputSchema": entity.input_schema,
+ "parameters": entity.input_schema,
+ "service_name": entity.service_original_name,
+ "service_global_name": entity.service_global_name,
+ "tool_global_name": entity.tool_global_name,
+ "source_agent": entity.source_agent
+ }
+
+ self._logger.debug(f"[TOOL_MANAGER] [GET] Retrieved {len(tools_dict)} tools: agent_id={agent_id}")
+ return tools_dict
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to get tools dict asynchronously {agent_id}: {e}")
+ return {}
+
+ def list_tools(self, agent_id: str) -> List['ToolInfo']:
+ """
+ 列出工具(返回ToolInfo对象)
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ ToolInfo对象列表
+ """
+ try:
+ # 获取工具字典
+ tools_dict = self.get_all_tools(agent_id)
+
+ # 转换为ToolInfo对象
+ tool_infos = []
+ for tool_name, tool_def in tools_dict.items():
+ tool_info = self._create_tool_info(tool_name, tool_def)
+ if tool_info:
+ tool_infos.append(tool_info)
+
+ self._logger.debug(f"[TOOL_MANAGER] [LIST] Listed tools: agent={agent_id}, count={len(tool_infos)}")
+ return tool_infos
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to list tools {agent_id}: {e}")
+ return []
+
+ def get_all_tool_info(self, agent_id: str) -> List[Dict[str, Any]]:
+ """
+ 获取所有工具的详细信息
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 工具详细信息列表
+ """
+ try:
+ # 获取工具字典
+ tools_dict = self.get_all_tools(agent_id)
+
+ # 生成详细信息
+ detailed_tools = []
+ for tool_name, tool_def in tools_dict.items():
+ detailed_tool = self._get_detailed_tool_info(agent_id, tool_name, tool_def)
+ if detailed_tool:
+ detailed_tools.append(detailed_tool)
+
+ self._logger.debug(f"[TOOL_MANAGER] [GET] Got all tool details: agent={agent_id}, count={len(detailed_tools)}")
+ return detailed_tools
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to get all tool details {agent_id}: {e}")
+ return []
+
+ def get_tools_for_service(self, agent_id: str, service_name: str) -> List[str]:
+ """
+ 获取指定服务的工具列表
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 工具名称列表
+ """
+ try:
+ # 使用缓存管理器执行同步操作
+ if self._cache_manager:
+ return self._cache_manager.async_to_sync(
+ self.get_tools_for_service_async(agent_id, service_name),
+ f"get_tools_for_service:{agent_id}:{service_name}"
+ )
+ else:
+ # 直接执行异步操作
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # 在新线程中运行
+ import concurrent.futures
+ import threading
+
+ def run_in_thread():
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ try:
+ return new_loop.run_until_complete(
+ self.get_tools_for_service_async(agent_id, service_name)
+ )
+ finally:
+ new_loop.close()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(run_in_thread)
+ return future.result()
+ else:
+ return loop.run_until_complete(
+ self.get_tools_for_service_async(agent_id, service_name)
+ )
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ return loop.run_until_complete(
+ self.get_tools_for_service_async(agent_id, service_name)
+ )
+ finally:
+ loop.close()
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to get service tools {agent_id}:{service_name}: {e}")
+ return []
+
+ async def get_tools_for_service_async(self, agent_id: str, service_name: str) -> List[str]:
+ """
+ 异步获取指定服务的工具列表
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 工具名称列表
+ """
+ try:
+ # 生成服务全局名称
+ service_global_name = self._naming.generate_service_global_name(service_name, agent_id)
+
+ # 获取服务的工具关系
+ if self._relation_manager:
+ tool_relations = await self._relation_manager.get_service_tools(service_global_name)
+ else:
+ tool_relations = []
+
+ # 提取工具全局名称(用于与 tools_dict 匹配)
+ tool_names = []
+ for tool_rel in tool_relations:
+ tool_global_name = tool_rel.get("tool_global_name")
+ if tool_global_name:
+ tool_names.append(tool_global_name)
+
+ self._logger.debug(f"[TOOL_MANAGER] [GET] Got service tools: agent={agent_id}, service={service_name}, count={len(tool_names)}")
+ return tool_names
+
+ except Exception as e:
+ self._logger.error(f"[TOOL_MANAGER] [ERROR] Failed to get service tools asynchronously {agent_id}:{service_name}: {e}")
+ return []
+
+ def get_tool_info(self, agent_id: str, tool_name: str) -> Dict[str, Any]:
+ """
+ 获取工具信息
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+
+ Returns:
+ 工具信息字典
+ """
+ try:
+ # 检查缓存
+ cache_key = f"{agent_id}:{tool_name}"
+ if cache_key in self._tool_cache:
+ return self._tool_cache[cache_key]
+
+ # 获取工具列表(get_all_tools 返回的是列表,不是字典)
+ tools_list = self.get_all_tools(agent_id)
+
+ # 查找指定工具
+ for tool_def in tools_list:
+ tool_global_name = tool_def.get("name", "")
+ tool_original_name = tool_def.get("tool_original_name", "")
+ # 匹配工具名称:全局名称、原始名称或名称后缀
+ if (tool_global_name == tool_name or
+ tool_original_name == tool_name or
+ tool_global_name.endswith(f"_{tool_name}")):
+ detailed_info = self._get_detailed_tool_info(agent_id, tool_name, tool_def)
+
+ # 更新缓存
+ self._tool_cache[cache_key] = detailed_info
+
+ return detailed_info
+
+ self._logger.debug(f"[TOOL_MANAGER] [MISS] Tool not found: agent={agent_id}, tool={tool_name}")
+ return {}
+
+ except Exception as e:
+ self._logger.error(f"Failed to get tool info {agent_id}:{tool_name}: {e}")
+ return {}
+
+ def get_session_for_tool(self, agent_id: str, tool_name: str) -> Optional[Any]:
+ """
+ 获取工具对应的会话
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+
+ Returns:
+ 会话对象或None
+ """
+ # 这个方法需要与SessionManager协作
+ # 这里返回None,实际实现在主类中委托给SessionManager
+ return None
+
+ def _get_detailed_tool_info(self, agent_id: str, tool_name: str, tool_def: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 获取详细的工具信息
+
+ Args:
+ agent_id: Agent ID
+ tool_name: 工具名称
+ tool_def: 工具定义
+
+ Returns:
+ 详细工具信息
+ """
+ try:
+ detailed_info = tool_def.copy()
+
+ # 处理输入Schema
+ input_schema = tool_def.get("inputSchema", {})
+ if input_schema:
+ # 处理参数信息
+ parameters = {}
+ required_params = input_schema.get("required", [])
+ properties = input_schema.get("properties", {})
+
+ for param_name, param_info in properties.items():
+ parameters[param_name] = {
+ "type": self._schema_utils.extract_type_from_schema(param_info),
+ "description": self._schema_utils.extract_description_from_schema(param_info),
+ "default": self._schema_utils.get_default_value_from_schema(param_info),
+ "required": param_name in required_params,
+ "enum": param_info.get("enum") if "enum" in param_info else None
+ }
+
+ detailed_info["parameters"] = parameters
+ detailed_info["required_params"] = required_params
+
+ # 添加处理后的信息
+ detailed_info["formatted_description"] = self._format_tool_description(tool_def)
+ detailed_info["parameter_count"] = len(input_schema.get("properties", {}))
+ detailed_info["has_parameters"] = bool(input_schema.get("properties"))
+
+ return detailed_info
+
+ except Exception as e:
+ self._logger.error(f"Failed to get detailed tool info {agent_id}:{tool_name}: {e}")
+ return tool_def.copy()
+
+ def _create_tool_info(self, tool_name: str, tool_def: Dict[str, Any]) -> Optional['ToolInfo']:
+ """
+ 创建ToolInfo对象
+
+ Args:
+ tool_name: 工具名称
+ tool_def: 工具定义
+
+ Returns:
+ ToolInfo对象或None
+ """
+ try:
+ # 这里应该创建实际的ToolInfo对象
+ # 由于ToolInfo类的具体定义未知,返回基本信息
+ return {
+ "name": tool_name,
+ "description": tool_def.get("description", ""),
+ "input_schema": tool_def.get("inputSchema", {}),
+ "service_name": tool_def.get("service_name", "")
+ }
+
+ except Exception as e:
+ self._logger.error(f"Failed to create ToolInfo {tool_name}: {e}")
+ return None
+
+ def _format_tool_description(self, tool_def: Dict[str, Any]) -> str:
+ """
+ 格式化工具描述
+
+ Args:
+ tool_def: 工具定义
+
+ Returns:
+ 格式化的描述
+ """
+ description = tool_def.get("description", "")
+ if not description:
+ return "无描述"
+
+ # 基本格式化
+ description = description.strip()
+
+ # 限制长度
+ if len(description) > 200:
+ description = description[:197] + "..."
+
+ return description
+
+ def search_tools(self, agent_id: str, query: str) -> List[Dict[str, Any]]:
+ """
+ 搜索工具
+
+ Args:
+ agent_id: Agent ID
+ query: 搜索查询
+
+ Returns:
+ 匹配的工具列表
+ """
+ try:
+ # 获取所有工具
+ all_tools = self.get_all_tools(agent_id)
+
+ # 转换为小写进行搜索
+ query_lower = query.lower()
+
+ # 搜索匹配的工具
+ matched_tools = []
+ for tool in all_tools:
+ # 搜索工具名称
+ tool_name = tool.get("name", "").lower()
+ if query_lower in tool_name:
+ matched_tools.append(tool)
+ continue
+
+ # 搜索描述
+ description = tool.get("description", "").lower()
+ if query_lower in description:
+ matched_tools.append(tool)
+ continue
+
+ # 搜索服务名称
+ service_name = tool.get("service_name", "").lower()
+ if query_lower in service_name:
+ matched_tools.append(tool)
+ continue
+
+ self._logger.debug(f"Searching tools: agent={agent_id}, query={query}, found={len(matched_tools)}")
+ return matched_tools
+
+ except Exception as e:
+ self._logger.error(f"Failed to search tools {agent_id}:{query}: {e}")
+ return []
+
+ def get_tool_stats(self, agent_id: str) -> Dict[str, Any]:
+ """
+ 获取工具统计信息
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 统计信息字典
+ """
+ try:
+ # 获取所有工具
+ tools = self.get_all_tools(agent_id)
+
+ # 统计信息
+ stats = {
+ "total_tools": len(tools),
+ "tools_with_params": 0,
+ "tools_without_params": 0,
+ "services": set(),
+ "parameter_counts": []
+ }
+
+ for tool in tools:
+ # 统计参数情况
+ input_schema = tool.get("inputSchema", {})
+ properties = input_schema.get("properties", {})
+
+ if properties:
+ stats["tools_with_params"] += 1
+ stats["parameter_counts"].append(len(properties))
+ else:
+ stats["tools_without_params"] += 1
+
+ # 统计服务
+ service_name = tool.get("service_name", "")
+ if service_name:
+ stats["services"].add(service_name)
+
+ # 计算平均参数数量
+ if stats["parameter_counts"]:
+ stats["avg_parameters"] = sum(stats["parameter_counts"]) / len(stats["parameter_counts"])
+ stats["max_parameters"] = max(stats["parameter_counts"])
+ stats["min_parameters"] = min(stats["parameter_counts"])
+ else:
+ stats["avg_parameters"] = 0
+ stats["max_parameters"] = 0
+ stats["min_parameters"] = 0
+
+ # 转换set为list
+ stats["services"] = list(stats["services"])
+ stats["service_count"] = len(stats["services"])
+ del stats["parameter_counts"]
+
+ return stats
+
+ except Exception as e:
+ self._logger.error(f"Failed to get tool statistics {agent_id}: {e}")
+ return {
+ "total_tools": 0,
+ "error": str(e)
+ }
+
+ def clear_tool_cache(self, agent_id: Optional[str] = None):
+ """
+ 清理工具缓存
+
+ Args:
+ agent_id: 可选的agent_id过滤,如果为None则清理所有
+ """
+ try:
+ if agent_id:
+ # 清理指定agent的缓存
+ keys_to_remove = []
+ cache_prefix = f"{agent_id}:"
+
+ for cache_key in self._tool_cache:
+ if cache_key.startswith(cache_prefix):
+ keys_to_remove.append(cache_key)
+
+ for key in keys_to_remove:
+ del self._tool_cache[key]
+
+ self._logger.debug(f"Clearing agent tool cache: {agent_id}")
+ else:
+ # 清理所有缓存
+ self._tool_cache.clear()
+ self._logger.debug("Clearing all tool cache")
+
+ except Exception as e:
+ self._logger.error(f"Failed to clear tool cache: {e}")
+
+ def get_stats(self) -> Dict[str, Any]:
+ """
+ 获取工具管理器的统计信息
+
+ Returns:
+ 统计信息字典
+ """
+ return {
+ "namespace": self._namespace,
+ "tool_cache_size": len(self._tool_cache),
+ "has_relation_manager": self._relation_manager is not None,
+ "has_tool_entity_manager": self._tool_entity_manager is not None,
+ "has_cache_manager": self._cache_manager is not None
+ }
diff --git a/src/mcpstore/core/registry/core_registry/utils.py b/src/mcpstore/core/registry/core_registry/utils.py
new file mode 100644
index 00000000..6dd463d8
--- /dev/null
+++ b/src/mcpstore/core/registry/core_registry/utils.py
@@ -0,0 +1,579 @@
+"""
+Utils - 工具函数模块
+
+包含从原 core_registry.py 中提取的工具函数,包括:
+1. JSON Schema 解析相关函数
+2. 数据类型推断函数
+3. 配置处理工具函数
+4. 其他辅助函数
+"""
+
+import json
+import logging
+from typing import Dict, Any, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+class JSONSchemaUtils:
+ """JSON Schema 处理工具类"""
+
+ @staticmethod
+ def extract_description_from_schema(prop_info: Dict[str, Any]) -> str:
+ """
+ 从JSON Schema属性信息中提取描述
+
+ Args:
+ prop_info: JSON Schema属性信息
+
+ Returns:
+ 描述字符串
+ """
+ if not isinstance(prop_info, dict):
+ return "参数"
+
+ # 优先使用 description 字段
+ description = prop_info.get('description')
+ if description:
+ return description
+
+ # 尝试从其他字段推断
+ if prop_info.get('type'):
+ type_desc = prop_info['type']
+ if isinstance(type_desc, list):
+ type_desc = " 或 ".join(type_desc)
+ return f"{type_desc} 类型参数"
+
+ # 检查 enum 值
+ if 'enum' in prop_info:
+ enum_values = prop_info['enum']
+ if enum_values:
+ values_str = ", ".join(str(v) for v in enum_values[:3])
+ if len(enum_values) > 3:
+ values_str += "..."
+ return f"可选值: {values_str}"
+
+ return "参数"
+
+ @staticmethod
+ def extract_type_from_schema(prop_info: Dict[str, Any]) -> str:
+ """
+ 从JSON Schema属性信息中提取类型
+
+ Args:
+ prop_info: JSON Schema属性信息
+
+ Returns:
+ 类型字符串
+ """
+ if not isinstance(prop_info, dict):
+ return "any"
+
+ # 获取类型信息
+ prop_type = prop_info.get('type')
+
+ if prop_type is None:
+ # 检查其他类型指示字段
+ if 'enum' in prop_info:
+ return "enum"
+ elif 'const' in prop_info:
+ return "const"
+ elif 'anyOf' in prop_info:
+ return "anyOf"
+ elif 'oneOf' in prop_info:
+ return "oneOf"
+ elif 'allOf' in prop_info:
+ return "allOf"
+ else:
+ return "any"
+
+ # 处理类型是列表的情况(联合类型)
+ if isinstance(prop_type, list):
+ # 过滤掉 null
+ non_null_types = [t for t in prop_type if t != 'null']
+ if not non_null_types:
+ return "null"
+ elif len(non_null_types) == 1:
+ return non_null_types[0]
+ else:
+ return f"({' | '.join(non_null_types)})"
+
+ return str(prop_type)
+
+ @staticmethod
+ def get_default_value_from_schema(prop_info: Dict[str, Any]) -> Any:
+ """
+ 从JSON Schema属性信息中获取默认值
+
+ Args:
+ prop_info: JSON Schema属性信息
+
+ Returns:
+ 默认值或None
+ """
+ if not isinstance(prop_info, dict):
+ return None
+
+ # 优先使用 default 字段
+ if 'default' in prop_info:
+ return prop_info['default']
+
+ # 对于布尔类型,默认为 False
+ if prop_info.get('type') == 'boolean':
+ return False
+
+ # 对于数组类型,默认为空数组
+ if prop_info.get('type') == 'array':
+ return []
+
+ # 对于对象类型,默认为空对象
+ if prop_info.get('type') == 'object':
+ return {}
+
+ # 对于字符串类型,默认为空字符串
+ if prop_info.get('type') == 'string':
+ return ""
+
+ # 对于数字类型,默认为 0
+ if prop_info.get('type') in ['number', 'integer']:
+ return 0
+
+ return None
+
+ @staticmethod
+ def is_required_parameter(prop_name: str, required_params: List[str]) -> bool:
+ """
+ 检查参数是否为必需参数
+
+ Args:
+ prop_name: 参数名称
+ required_params: 必需参数列表
+
+ Returns:
+ 是否为必需参数
+ """
+ return prop_name in required_params
+
+ @staticmethod
+ def format_parameter_info(param_name: str, prop_info: Dict[str, Any],
+ required_params: List[str]) -> Dict[str, Any]:
+ """
+ 格式化参数信息
+
+ Args:
+ param_name: 参数名称
+ prop_info: JSON Schema属性信息
+ required_params: 必需参数列表
+
+ Returns:
+ 格式化的参数信息
+ """
+ return {
+ 'name': param_name,
+ 'type': JSONSchemaUtils.extract_type_from_schema(prop_info),
+ 'description': JSONSchemaUtils.extract_description_from_schema(prop_info),
+ 'default': JSONSchemaUtils.get_default_value_from_schema(prop_info),
+ 'required': JSONSchemaUtils.is_required_parameter(param_name, required_params),
+ 'enum': prop_info.get('enum') if 'enum' in prop_info else None
+ }
+
+
+class ConfigUtils:
+ """配置处理工具类"""
+
+ @staticmethod
+ def validate_service_config_structure(config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 验证服务配置结构
+
+ Args:
+ config: 服务配置
+
+ Returns:
+ 验证结果
+ """
+ result = {
+ 'valid': True,
+ 'errors': [],
+ 'warnings': []
+ }
+
+ # 检查基本结构
+ if not isinstance(config, dict):
+ result['valid'] = False
+ result['errors'].append("配置必须是一个字典")
+ return result
+
+ # 检查是否为空配置
+ if not config:
+ result['valid'] = False
+ result['errors'].append("配置不能为空")
+ return result
+
+ # 检查必要的连接信息
+ has_command = 'command' in config
+ has_url = 'url' in config
+
+ if not (has_command or has_url):
+ result['valid'] = False
+ result['errors'].append("必须包含 'command' 或 'url' 字段")
+
+ # 验证命令配置
+ if has_command:
+ command = config.get('command')
+ if not isinstance(command, str) or not command.strip():
+ result['valid'] = False
+ result['errors'].append("'command' 必须是非空字符串")
+
+ # 验证参数
+ args = config.get('args', [])
+ if args is not None and not isinstance(args, list):
+ result['errors'].append("'args' 必须是数组类型")
+ result['warnings'].append("将 'args' 重置为空数组")
+
+ # 验证URL配置
+ if has_url:
+ url = config.get('url')
+ if not isinstance(url, str) or not url.strip():
+ result['valid'] = False
+ result['errors'].append("'url' 必须是非空字符串")
+ elif not (url.startswith('http://') or url.startswith('https://')):
+ result['warnings'].append("URL 建议以 http:// 或 https:// 开头")
+
+ # 验证环境变量
+ if 'env' in config:
+ env = config['env']
+ if env is not None and not isinstance(env, dict):
+ result['errors'].append("'env' 必须是字典类型")
+ result['warnings'].append("将 'env' 重置为空字典")
+
+ return result
+
+ @staticmethod
+ def normalize_service_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 标准化服务配置
+
+ Args:
+ config: 原始配置
+
+ Returns:
+ 标准化后的配置
+ """
+ normalized = config.copy()
+
+ # 确保 args 是列表
+ if 'args' in normalized and normalized['args'] is None:
+ normalized['args'] = []
+ elif 'args' not in normalized:
+ normalized['args'] = []
+
+ # 确保 env 是字典
+ if 'env' in normalized and normalized['env'] is None:
+ normalized['env'] = {}
+ elif 'env' not in normalized:
+ normalized['env'] = {}
+
+ # 设置默认的传输类型
+ if 'transport_type' not in normalized:
+ if 'command' in normalized:
+ normalized['transport_type'] = 'stdio'
+ elif 'url' in normalized:
+ normalized['transport_type'] = 'http'
+
+ return normalized
+
+ @staticmethod
+ def merge_service_configs(base_config: Dict[str, Any],
+ override_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 合并服务配置
+
+ Args:
+ base_config: 基础配置
+ override_config: 覆盖配置
+
+ Returns:
+ 合并后的配置
+ """
+ merged = base_config.copy()
+
+ for key, value in override_config.items():
+ if key in ['args', 'env']:
+ # 对于数组和字典类型的字段,进行合并而不是覆盖
+ if isinstance(value, dict):
+ merged.setdefault(key, {}).update(value)
+ elif isinstance(value, list):
+ merged.setdefault(key, []).extend(value)
+ else:
+ # 其他字段直接覆盖
+ merged[key] = value
+
+ return merged
+
+
+class ServiceUtils:
+ """服务相关工具类"""
+
+ @staticmethod
+ def generate_service_id(agent_id: str, service_name: str) -> str:
+ """
+ 生成服务ID
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务ID
+ """
+ return f"{agent_id}::{service_name}"
+
+ @staticmethod
+ def parse_service_id(service_id: str) -> Tuple[str, str]:
+ """
+ 解析服务ID
+
+ Args:
+ service_id: 服务ID
+
+ Returns:
+ (agent_id, service_name) 元组
+ """
+ if '::' not in service_id:
+ raise ValueError(f"Invalid service ID format: {service_id}")
+
+ parts = service_id.split('::', 1)
+ if len(parts) != 2:
+ raise ValueError(f"Invalid service ID format: {service_id}")
+
+ return parts[0], parts[1]
+
+ @staticmethod
+ def is_valid_service_name(service_name: str) -> bool:
+ """
+ 验证服务名称是否有效
+
+ Args:
+ service_name: 服务名称
+
+ Returns:
+ 是否有效
+ """
+ if not service_name or not isinstance(service_name, str):
+ return False
+
+ # 服务名称不能包含特殊字符
+ invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
+ if any(char in service_name for char in invalid_chars):
+ return False
+
+ # 长度限制
+ if len(service_name) > 100:
+ return False
+
+ return True
+
+ @staticmethod
+ def sanitize_service_name(service_name: str) -> str:
+ """
+ 清理服务名称,移除无效字符
+
+ Args:
+ service_name: 原始服务名称
+
+ Returns:
+ 清理后的服务名称
+ """
+ if not service_name:
+ return "unnamed_service"
+
+ # 移除无效字符
+ invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
+ sanitized = service_name
+ for char in invalid_chars:
+ sanitized = sanitized.replace(char, '_')
+
+ # 长度限制
+ if len(sanitized) > 100:
+ sanitized = sanitized[:97] + '...'
+
+ # 确保不为空
+ if not sanitized.strip():
+ sanitized = "unnamed_service"
+
+ return sanitized.strip()
+
+
+class DataUtils:
+ """数据处理工具类"""
+
+ @staticmethod
+ def deep_merge_dict(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 深度合并字典
+
+ Args:
+ base: 基础字典
+ override: 覆盖字典
+
+ Returns:
+ 合并后的字典
+ """
+ result = base.copy()
+
+ for key, value in override.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ result[key] = DataUtils.deep_merge_dict(result[key], value)
+ else:
+ result[key] = value
+
+ return result
+
+ @staticmethod
+ def safe_json_serialize(obj: Any) -> Optional[str]:
+ """
+ 安全的JSON序列化
+
+ Args:
+ obj: 要序列化的对象
+
+ Returns:
+ JSON字符串或None
+ """
+ try:
+ return json.dumps(obj, default=str, ensure_ascii=False)
+ except Exception as e:
+ logger.warning(f"JSON serialization failed: {e}")
+ return None
+
+ @staticmethod
+ def safe_json_deserialize(json_str: str) -> Optional[Any]:
+ """
+ 安全的JSON反序列化
+
+ Args:
+ json_str: JSON字符串
+
+ Returns:
+ 反序列化的对象或None
+ """
+ try:
+ return json.loads(json_str)
+ except Exception as e:
+ logger.warning(f"JSON deserialization failed: {e}")
+ return None
+
+ @staticmethod
+ def flatten_dict(d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]:
+ """
+ 扁平化字典
+
+ Args:
+ d: 要扁平化的字典
+ parent_key: 父键名
+ sep: 分隔符
+
+ Returns:
+ 扁平化后的字典
+ """
+ items = []
+ for k, v in d.items():
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
+ if isinstance(v, dict):
+ items.extend(DataUtils.flatten_dict(v, new_key, sep=sep).items())
+ else:
+ items.append((new_key, v))
+ return dict(items)
+
+
+class ValidationUtils:
+ """验证工具类"""
+
+ @staticmethod
+ def validate_agent_id(agent_id: str) -> bool:
+ """
+ 验证Agent ID是否有效
+
+ Args:
+ agent_id: Agent ID
+
+ Returns:
+ 是否有效
+ """
+ if not agent_id or not isinstance(agent_id, str):
+ return False
+
+ # 基本长度检查
+ if len(agent_id) < 1 or len(agent_id) > 100:
+ return False
+
+ # 字符检查:只允许字母、数字、下划线、连字符
+ import re
+ pattern = r'^[a-zA-Z0-9_-]+$'
+ return bool(re.match(pattern, agent_id))
+
+ @staticmethod
+ def validate_global_name(global_name: str) -> bool:
+ """
+ 验证全局名称是否有效
+
+ Args:
+ global_name: 全局名称
+
+ Returns:
+ 是否有效
+ """
+ if not global_name or not isinstance(global_name, str):
+ return False
+
+ # 基本格式检查:应该包含agent_id和服务名
+ if '::' not in global_name:
+ return False
+
+ try:
+ parts = global_name.split('::', 1)
+ agent_id, service_name = parts
+ return (ValidationUtils.validate_agent_id(agent_id) and
+ ServiceUtils.is_valid_service_name(service_name))
+ except Exception:
+ return False
+
+ @staticmethod
+ def sanitize_agent_id(agent_id: str) -> str:
+ """
+ 清理Agent ID
+
+ Args:
+ agent_id: 原始Agent ID
+
+ Returns:
+ 清理后的Agent ID
+ """
+ if not agent_id:
+ return "unknown_agent"
+
+ # 移除无效字符,只保留字母、数字、下划线、连字符
+ import re
+ sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', agent_id)
+
+ # 长度限制
+ if len(sanitized) > 100:
+ sanitized = sanitized[:97] + '...'
+
+ # 确保不为空
+ if not sanitized.strip():
+ sanitized = "unknown_agent"
+
+ return sanitized.strip()
+
+
+# 便捷函数,保持向后兼容
+def extract_description_from_schema(prop_info: Dict[str, Any]) -> str:
+ """向后兼容的函数"""
+ return JSONSchemaUtils.extract_description_from_schema(prop_info)
+
+
+def extract_type_from_schema(prop_info: Dict[str, Any]) -> str:
+ """向后兼容的函数"""
+ return JSONSchemaUtils.extract_type_from_schema(prop_info)
diff --git a/src/mcpstore/core/registry/delegation_generator.py b/src/mcpstore/core/registry/delegation_generator.py
new file mode 100644
index 00000000..55f2b61e
--- /dev/null
+++ b/src/mcpstore/core/registry/delegation_generator.py
@@ -0,0 +1,207 @@
+"""
+自动化委托方法生成器 - 优雅解决 ServiceRegistry 缺失方法问题
+
+这个模块提供了一个优雅的解决方案,通过自动生成委托方法来处理
+ServiceRegistry 重构后的接口兼容性问题,而不是手动一个一个添加委托方法。
+"""
+
+import inspect
+import logging
+from typing import Any, Dict, Optional, Set, Type
+
+logger = logging.getLogger(__name__)
+
+
+class DelegationGenerator:
+ """
+ 自动化委托方法生成器
+
+ 这个类可以自动扫描服务类的方法,并在目标类中生成对应的委托方法,
+ 确保向后兼容性和接口一致性。
+ """
+
+ def __init__(self, target_class: Type):
+ """
+ 初始化委托生成器
+
+ Args:
+ target_class: 要添加委托方法的目标类(通常是 ServiceRegistry)
+ """
+ self.target_class = target_class
+ self.delegated_methods: Set[str] = set()
+
+ def generate_delegation_methods(
+ self,
+ service_instance: Any,
+ service_name: str,
+ method_filter: Optional[callable] = None
+ ) -> None:
+ """
+ 为指定的服务实例生成委托方法
+
+ Args:
+ service_instance: 服务实例(如 self._service_state_service)
+ service_name: 服务名称(用于错误消息)
+ method_filter: 可选的方法过滤器函数
+ """
+ service_class = service_instance.__class__
+
+ # 获取所有公共方法
+ for method_name, method in inspect.getmembers(service_class, predicate=inspect.ismethod):
+ if method_name.startswith('_'):
+ continue # 跳过私有方法
+
+ # 应用方法过滤器
+ if method_filter and not method_filter(method_name, method):
+ continue
+
+ # 检查目标类是否已有此方法
+ if hasattr(self.target_class, method_name):
+ continue # 跳过已存在的方法
+
+ # 生成委托方法
+ try:
+ self._generate_single_delegation_method(service_instance, method_name, service_name)
+ self.delegated_methods.add(method_name)
+ except Exception as e:
+ logger.warning(f"Failed to generate delegation for {service_name}.{method_name}: {e}")
+
+ # 处理静态方法和类方法
+ for attr_name in dir(service_class):
+ if attr_name.startswith('_'):
+ continue
+
+ attr = getattr(service_class, attr_name)
+ if inspect.isfunction(attr):
+ if hasattr(self.target_class, attr_name):
+ continue
+
+ # 应用方法过滤器
+ if method_filter and not method_filter(attr_name, attr):
+ continue
+
+ try:
+ self._generate_single_delegation_method(service_instance, attr_name, service_name)
+ self.delegated_methods.add(attr_name)
+ except Exception as e:
+ logger.warning(f"Failed to generate delegation for {service_name}.{attr_name}: {e}")
+
+ def _generate_single_delegation_method(self, service_instance: Any, method_name: str, service_name: str):
+ """
+ 生成单个委托方法
+
+ Args:
+ service_instance: 服务实例
+ method_name: 方法名称
+ service_name: 服务名称
+ """
+ method = getattr(service_instance.__class__, method_name)
+ signature = inspect.signature(method)
+
+ # 生成方法文档
+ docstring = f"""委托方法: {service_name}.{method_name}
+
+ 这个方法自动生成,委托给 {service_name} 服务处理。
+ 参见 {service_name}.{method_name} 的详细文档。
+ """
+
+ # 生成方法体
+ method_body = self._generate_method_body(service_instance, method_name, signature)
+
+ # 在目标类中添加方法
+ setattr(self.target_class, method_name, method_body)
+
+ def _generate_method_body(self, service_instance: Any, method_name: str, signature) -> callable:
+ """
+ 生成方法体
+
+ Args:
+ service_instance: 服务实例
+ method_name: 方法名称
+ signature: 方法签名
+
+ Returns:
+ 生成的方法
+ """
+ # 获取服务实例的属性名(如 self._service_state_service)
+ service_attr_name = None
+ for attr_name, attr_value in self.target_class.__dict__.items():
+ if attr_value is service_instance:
+ service_attr_name = attr_name
+ break
+
+ if not service_attr_name:
+ raise ValueError(f"Cannot find service instance attribute for {service_instance}")
+
+ # 根据方法签名生成适当的方法
+ if inspect.iscoroutinefunction(getattr(service_instance.__class__, method_name)):
+ return self._generate_async_method_body(service_attr_name, method_name, signature)
+ else:
+ return self._generate_sync_method_body(service_attr_name, method_name, signature)
+
+ def _generate_sync_method_body(self, service_attr_name: str, method_name: str, signature) -> callable:
+ """生成同步方法体"""
+ def delegating_method(self, *args, **kwargs):
+ """自动生成的委托方法"""
+ service = getattr(self, service_attr_name)
+ method = getattr(service, method_name)
+ return method(*args, **kwargs)
+
+ # 设置方法签名和文档
+ delegating_method.__name__ = method_name
+ delegating_method.__qualname__ = f"{self.target_class.__name__}.{method_name}"
+ delegating_method.__signature__ = signature
+ delegating_method.__doc__ = f"""委托方法: {service_attr_name}.{method_name}"""
+
+ return delegating_method
+
+ def _generate_async_method_body(self, service_attr_name: str, method_name: str, signature) -> callable:
+ """生成异步方法体"""
+ async def async_delegating_method(self, *args, **kwargs):
+ """自动生成的异步委托方法"""
+ service = getattr(self, service_attr_name)
+ method = getattr(service, method_name)
+ return await method(*args, **kwargs)
+
+ # 设置方法签名和文档
+ async_delegating_method.__name__ = method_name
+ async_delegating_method.__qualname__ = f"{self.target_class.__name__}.{method_name}"
+ async_delegating_method.__signature__ = signature
+ async_delegating_method.__doc__ = f"""异步委托方法: {service_attr_name}.{method_name}"""
+
+ return async_delegating_method
+
+
+def auto_generate_delegations(target_instance: Any, service_mappings: Dict[str, Any]) -> None:
+ """
+ 自动生成所有委托方法的便捷函数
+
+ Args:
+ target_instance: 目标实例(通常是 ServiceRegistry 实例)
+ service_mappings: 服务映射字典 {service_name: service_instance}
+ """
+ generator = DelegationGenerator(target_instance.__class__)
+
+ for service_name, service_instance in service_mappings.items():
+ logger.info(f"Generating delegation methods for {service_name}")
+
+ # 定义方法过滤器 - 只生成公共方法,跳过已经存在的方法
+ def method_filter(method_name: str, method: Any) -> bool:
+ # 跳过特殊方法
+ if method_name.startswith('_'):
+ return False
+ # 跳过已存在的方法
+ if hasattr(target_instance.__class__, method_name):
+ return False
+ # 只生成函数方法
+ if not (inspect.isfunction(method) or inspect.ismethod(method)):
+ return False
+ return True
+
+ generator.generate_delegation_methods(
+ service_instance,
+ service_name,
+ method_filter
+ )
+
+ logger.info(f"Generated {len(generator.delegated_methods)} delegation methods: {sorted(generator.delegated_methods)}")
\ No newline at end of file
diff --git a/src/mcpstore/core/registry/elegant_registry.py b/src/mcpstore/core/registry/elegant_registry.py
new file mode 100644
index 00000000..26d710fb
--- /dev/null
+++ b/src/mcpstore/core/registry/elegant_registry.py
@@ -0,0 +1,197 @@
+"""
+优雅的注册表实现 - 组合模式 + 接口抽象
+展示真正的工厂类设计模式
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import Dict, Any, List, Optional, Protocol, Type
+
+logger = logging.getLogger(__name__)
+
+# === 1. 定义接口协议(Protocol是更现代的抽象方式) ===
+
+class IServiceStateService(Protocol):
+ """服务状态服务接口"""
+ def get_service_state(self, agent_id: str, service_name: str): ...
+ def set_service_state(self, agent_id: str, service_name: str, state): ...
+ async def get_service_metadata_async(self, agent_id: str, service_name: str): ...
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata): ...
+ def has_service(self, agent_id: str, service_name: str) -> bool: ...
+
+class IAgentClientMappingService(Protocol):
+ """代理客户端映射服务接口"""
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]: ...
+ def add_service_client_mapping(self, agent_id: str, service_name: str, client_id: str): ...
+ def get_service_client_id(self, agent_id: str, service_name: str): ...
+
+class IClientConfigService(Protocol):
+ """客户端配置服务接口"""
+ def get_client_config_from_cache(self, client_id: str) -> Optional[Dict[str, Any]]: ...
+ def add_client_config(self, client_id: str, config: Dict[str, Any]): ...
+
+# === 2. 服务工厂类(真正的工厂模式) ===
+
+@dataclass
+class RegistryServiceFactory:
+ """注册表服务工厂 - 优雅的工厂模式实现"""
+
+ # 使用Protocol类型注解,支持依赖注入
+ service_state_service: IServiceStateService
+ agent_client_service: IAgentClientMappingService
+ client_config_service: IClientConfigService
+
+ @classmethod
+ def create(cls,
+ service_state_impl: Type,
+ agent_client_impl: Type,
+ client_config_impl: Type,
+ **kwargs) -> 'RegistryServiceFactory':
+ """
+ 工厂方法 - 根据具体实现类创建工厂实例
+
+ Args:
+ service_state_impl: 服务状态服务的具体实现类
+ agent_client_impl: 代理客户端映射服务的具体实现类
+ client_config_impl: 客户端配置服务的具体实现类
+ **kwargs: 传递给实现类的参数
+
+ Returns:
+ RegistryServiceFactory: 工厂实例
+ """
+ # 真正的工厂模式 - 创建具体服务实例
+ service_state_service = service_state_impl(**kwargs)
+ agent_client_service = agent_client_impl(**kwargs)
+ client_config_service = client_config_impl(**kwargs)
+
+ return cls(
+ service_state_service=service_state_service,
+ agent_client_service=agent_client_service,
+ client_config_service=client_config_service
+ )
+
+ def create_service_registry(self) -> 'ElegantServiceRegistry':
+ """
+ 工厂方法 - 创建服务注册表实例
+
+ Returns:
+ ElegantServiceRegistry: 优雅的服务注册表
+ """
+ return ElegantServiceRegistry(factory=self)
+
+# === 3. 优雅的注册表实现(组合模式) ===
+
+class ElegantServiceRegistry:
+ """优雅的服务注册表 - 使用组合模式和工厂模式"""
+
+ def __init__(self, factory: RegistryServiceFactory):
+ """
+ 通过工厂注入所有服务依赖
+
+ Args:
+ factory: 服务工厂实例
+ """
+ self._factory = factory
+ self._services = {
+ 'state': factory.service_state_service,
+ 'client_mapping': factory.agent_client_service,
+ 'client_config': factory.client_config_service
+ }
+
+ logger.info("ElegantServiceRegistry initialized with dependency injection")
+
+ # === 动态方法代理 - 使用__getattr__实现优雅的委托 ===
+
+ def __getattr__(self, name: str):
+ """
+ 动态方法代理 - 优雅的委托模式
+
+ 当访问不存在的方法时,自动查找并调用对应的服务方法
+ """
+ # 查找哪个服务有这个方法
+ for service_name, service in self._services.items():
+ if hasattr(service, name):
+ method = getattr(service, name)
+ logger.debug(f"Method '{name}' proxied to {service_name}")
+ return method
+
+ # 如果没有找到,抛出更清晰的错误
+ available_methods = []
+ for service_name, service in self._services.items():
+ available_methods.extend([f"{service_name}.{m}" for m in dir(service) if not m.startswith('_')])
+
+ raise AttributeError(
+ f"Method '{name}' not found in any service. "
+ f"Available methods: {available_methods[:10]}..." # 只显示前10个避免太长
+ )
+
+ # === 显式委托方法(可选,用于性能关键路径) ===
+
+ def get_service_state(self, agent_id: str, service_name: str):
+ """显式委托方法 - 性能优化"""
+ return self._factory.service_state_service.get_service_state(agent_id, service_name)
+
+ async def get_service_metadata_async(self, agent_id: str, service_name: str):
+ """显式委托方法 - 从 pykv 异步获取元数据"""
+ return await self._factory.service_state_service.get_service_metadata_async(agent_id, service_name)
+
+ def set_service_metadata(self, agent_id: str, service_name: str, metadata):
+ """显式委托方法 - 性能优化"""
+ return self._factory.service_state_service.set_service_metadata(agent_id, service_name, metadata)
+
+ def has_service(self, agent_id: str, service_name: str) -> bool:
+ """显式委托方法 - 性能优化"""
+ return self._factory.service_state_service.has_service(agent_id, service_name)
+
+ async def has_service_async(self, agent_id: str, service_name: str) -> bool:
+ """
+ 异步检查指定 Agent 是否拥有指定服务
+
+ 遵循 "Functional Core, Imperative Shell" 架构原则:
+ - 异步外壳直接使用 await 调用异步操作
+ - 在异步上下文中必须使用此方法,而非同步版本
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 服务是否存在
+ """
+ return await self._factory.service_state_service.has_service_async(agent_id, service_name)
+
+ async def get_agent_clients_async(self, agent_id: str) -> List[str]:
+ """显式委托方法 - 从 pykv 获取 Agent 客户端"""
+ return await self._factory.agent_client_service.get_agent_clients_async(agent_id)
+
+ def get_client_config_from_cache(self, client_id: str) -> Optional[Dict[str, Any]]:
+ """显式委托方法 - 性能优化"""
+ return self._factory.client_config_service.get_client_config_from_cache(client_id)
+
+ def add_client_config(self, client_id: str, config: Dict[str, Any]):
+ """显式委托方法 - 性能优化"""
+ return self._factory.client_config_service.add_client_config(client_id, config)
+
+ # === 组合模式的高级功能 ===
+
+ def replace_service(self, service_type: str, new_service):
+ """
+ 运行时替换服务实现 - 真正的组合模式优势
+
+ Args:
+ service_type: 服务类型 ('state', 'client_mapping', 'client_config')
+ new_service: 新的服务实例
+ """
+ if service_type in self._services:
+ old_service = self._services[service_type]
+ self._services[service_type] = new_service
+ logger.info(f"Replaced {service_type} service: {type(old_service)} -> {type(new_service)}")
+ else:
+ raise ValueError(f"Unknown service type: {service_type}")
+
+ def get_service_info(self) -> Dict[str, str]:
+ """获取当前服务信息 - 用于调试"""
+ return {
+ name: f"{type(service).__module__}.{type(service).__name__}"
+ for name, service in self._services.items()
+ }
diff --git a/src/mcpstore/core/registry/exception_mapper.py b/src/mcpstore/core/registry/exception_mapper.py
new file mode 100644
index 00000000..eb625135
--- /dev/null
+++ b/src/mcpstore/core/registry/exception_mapper.py
@@ -0,0 +1,276 @@
+"""
+Exception Mapper for py-key-value Integration
+
+This module provides utilities to map py-key-value exceptions to MCPStore exceptions,
+ensuring consistent error handling across the codebase.
+
+Validates: Requirements 6.4 (Exception and error handling)
+"""
+
+import logging
+from functools import wraps
+from typing import Any, Callable, TypeVar, ParamSpec
+
+from ..exceptions import (
+ CacheOperationError,
+ CacheConnectionError,
+ CacheValidationError,
+)
+
+logger = logging.getLogger(__name__)
+
+# Type variables for generic decorator
+P = ParamSpec('P')
+T = TypeVar('T')
+
+
+def map_kv_exception(func: Callable[P, T]) -> Callable[P, T]:
+ """
+ Decorator to map py-key-value exceptions to MCPStore exceptions.
+
+ This decorator wraps async functions that interact with py-key-value storage
+ and translates any py-key-value exceptions into appropriate MCPStore exceptions.
+
+ Exception Mapping:
+ - KeyValueOperationError → CacheOperationError
+ - SerializationError → CacheValidationError (validation_type="serialization")
+ - DeserializationError → CacheValidationError (validation_type="deserialization")
+ - MissingKeyError → CacheValidationError (validation_type="missing_key")
+ - InvalidTTLError → CacheValidationError (validation_type="invalid_ttl")
+ - StoreConnectionError → CacheConnectionError
+ - StoreSetupError → CacheConnectionError
+ - BaseKeyValueError → CacheOperationError (fallback)
+
+ Args:
+ func: The async function to wrap
+
+ Returns:
+ Wrapped function that maps exceptions
+
+ Example:
+ @map_kv_exception
+ async def get_tool_cache(self, agent_id: str) -> Dict[str, Any]:
+ collection = self._get_collection(agent_id, "tools")
+ return await self._kv_store.get(collection=collection)
+
+ Validates: Requirements 6.4 (Exception and error handling)
+ """
+ @wraps(func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
+ try:
+ return await func(*args, **kwargs)
+ except Exception as e:
+ # Try to import py-key-value exceptions
+ try:
+ from key_value.shared.errors.base import BaseKeyValueError
+ from key_value.shared.errors.key_value import (
+ KeyValueOperationError,
+ SerializationError,
+ DeserializationError,
+ MissingKeyError,
+ InvalidTTLError,
+ )
+ from key_value.shared.errors.store import (
+ StoreConnectionError,
+ StoreSetupError,
+ )
+ except ImportError:
+ # If py-key-value is not installed, just re-raise
+ logger.warning("py-key-value not installed, cannot map exceptions")
+ raise
+
+ # Extract context information for logging
+ context_info = f"operation={func.__name__}"
+ if args:
+ # Try to extract agent_id if it's the first argument after self
+ if len(args) > 1 and isinstance(args[1], str):
+ context_info += f", agent_id={args[1]}"
+
+ # Map py-key-value exceptions to MCPStore exceptions
+ if isinstance(e, SerializationError):
+ logger.error(f"Cache serialization error: {e} ({context_info})")
+ raise CacheValidationError(
+ message=f"Failed to serialize data: {e}",
+ validation_type="serialization",
+ cause=e
+ ) from e
+
+ elif isinstance(e, DeserializationError):
+ logger.error(f"Cache deserialization error: {e} ({context_info})")
+ raise CacheValidationError(
+ message=f"Failed to deserialize data: {e}",
+ validation_type="deserialization",
+ cause=e
+ ) from e
+
+ elif isinstance(e, MissingKeyError):
+ logger.error(f"Cache missing key error: {e} ({context_info})")
+ raise CacheValidationError(
+ message=f"Missing cache key: {e}",
+ validation_type="missing_key",
+ cause=e
+ ) from e
+
+ elif isinstance(e, InvalidTTLError):
+ logger.error(f"Cache invalid TTL error: {e} ({context_info})")
+ raise CacheValidationError(
+ message=f"Invalid TTL for cache: {e}",
+ validation_type="invalid_ttl",
+ cause=e
+ ) from e
+
+ elif isinstance(e, (StoreConnectionError, StoreSetupError)):
+ logger.error(f"Cache connection error: {e} ({context_info})")
+ raise CacheConnectionError(
+ message=f"Cache connection failed: {e}",
+ backend_type=type(e).__name__,
+ cause=e
+ ) from e
+
+ elif isinstance(e, KeyValueOperationError):
+ logger.error(f"Cache operation error: {e} ({context_info})")
+ raise CacheOperationError(
+ message=f"Cache operation failed: {e}",
+ operation=func.__name__,
+ cause=e
+ ) from e
+
+ elif isinstance(e, BaseKeyValueError):
+ # Fallback for any other py-key-value exceptions
+ logger.error(f"Cache error: {e} ({context_info})")
+ raise CacheOperationError(
+ message=f"Cache error: {e}",
+ operation=func.__name__,
+ cause=e
+ ) from e
+
+ else:
+ # Not a py-key-value exception, re-raise as-is
+ raise
+
+ return wrapper
+
+
+def validate_session_serializable(session: Any, agent_id: str, service_name: str) -> None:
+ """
+ Validate that a Session object does not contain non-serializable references.
+
+ This function performs defensive checks to ensure Session objects remain
+ in memory and are never accidentally serialized to py-key-value storage.
+
+ Args:
+ session: The Session object to validate
+ agent_id: Agent ID for error reporting
+ service_name: Service name for error reporting
+
+ Raises:
+ SessionSerializationError: If the session contains non-serializable references
+
+ Validates: Requirements 3.2 (Session object serialization issues)
+ """
+ from ..exceptions import SessionSerializationError
+
+ if session is None:
+ return
+
+ # Check for common non-serializable attributes
+ non_serializable_attrs = [
+ '_kv_store', # py-key-value store reference
+ '_connection', # Connection objects
+ '_socket', # Socket objects
+ '_stream', # Stream objects
+ '_transport', # Transport objects
+ '_protocol', # Protocol objects
+ 'session', # Nested session objects
+ 'client', # Client objects
+ ]
+
+ if hasattr(session, '__dict__'):
+ session_attrs = set(session.__dict__.keys())
+ found_attrs = [attr for attr in non_serializable_attrs if attr in session_attrs]
+
+ if found_attrs:
+ raise SessionSerializationError(
+ message=(
+ f"Session object contains non-serializable references: {found_attrs}. "
+ f"Session objects should remain in memory and never be serialized. "
+ f"Agent: {agent_id}, Service: {service_name}"
+ ),
+ session_info={
+ "agent_id": agent_id,
+ "service_name": service_name,
+ "non_serializable_attrs": found_attrs,
+ "session_type": type(session).__name__,
+ }
+ )
+
+ # Check if session has a to_dict or dict method (might indicate serialization attempt)
+ if hasattr(session, 'to_dict') or hasattr(session, 'dict'):
+ logger.warning(
+ f"Session object has serialization methods (to_dict/dict). "
+ f"Ensure it's not being serialized. Agent: {agent_id}, Service: {service_name}"
+ )
+
+ logger.debug(
+ f"Session validation passed for agent={agent_id}, service={service_name}, "
+ f"type={type(session).__name__}"
+ )
+
+
+def safe_session_operation(func: Callable[P, T]) -> Callable[P, T]:
+ """
+ Decorator to ensure Session operations are safe and don't attempt serialization.
+
+ This decorator wraps Session-related operations and validates that Session objects
+ are not being accidentally serialized or stored in py-key-value.
+
+ Args:
+ func: The function to wrap (can be sync or async)
+
+ Returns:
+ Wrapped function with Session validation
+
+ Example:
+ @safe_session_operation
+ def set_session(self, agent_id: str, service_name: str, session: Any) -> None:
+ if agent_id not in self.sessions:
+ self.sessions[agent_id] = {}
+ self.sessions[agent_id][service_name] = session
+
+ Validates: Requirements 3.2 (Session object serialization issues)
+ """
+ @wraps(func)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
+ # Extract session, agent_id, and service_name from arguments
+ # Assume common patterns: (self, agent_id, service_name, session) or similar
+ session = None
+ agent_id = None
+ service_name = None
+
+ # Try to extract from positional args
+ if len(args) >= 4:
+ # Pattern: (self, agent_id, service_name, session)
+ agent_id = args[1] if isinstance(args[1], str) else None
+ service_name = args[2] if isinstance(args[2], str) else None
+ session = args[3]
+ elif len(args) >= 3:
+ # Pattern: (self, agent_id, service_name) - getting session
+ agent_id = args[1] if isinstance(args[1], str) else None
+ service_name = args[2] if isinstance(args[2], str) else None
+
+ # Try to extract from kwargs
+ if 'session' in kwargs:
+ session = kwargs['session']
+ if 'agent_id' in kwargs:
+ agent_id = kwargs['agent_id']
+ if 'service_name' in kwargs:
+ service_name = kwargs['service_name']
+
+ # Validate session if we're setting it
+ if session is not None and agent_id and service_name:
+ validate_session_serializable(session, agent_id, service_name)
+
+ # Call the original function
+ return func(*args, **kwargs)
+
+ return wrapper
diff --git a/src/mcpstore/core/registry/key_builder.py b/src/mcpstore/core/registry/key_builder.py
new file mode 100644
index 00000000..e53bef56
--- /dev/null
+++ b/src/mcpstore/core/registry/key_builder.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class KeyBuilder:
+ """
+ Redis 缓存命名空间的键构建器。
+
+ 新的键布局使用三层架构:
+ {namespace}:entity:{entity_type}:{key}
+ {namespace}:relations:{relation_type}:{key}
+ {namespace}:state:{state_type}:{key}
+
+ 命名空间提供不同应用程序/环境之间的隔离。
+ 默认命名空间是从 mcp.json 路径自动生成的(5字符哈希)。
+ """
+
+ namespace: str = "mcpstore"
+
+ def base(self) -> str:
+ """返回基础键前缀: mcpstore:{namespace}"""
+ return f"mcpstore:{self.namespace}"
+
diff --git a/src/mcpstore/core/registry/kv_storage_adapter.py b/src/mcpstore/core/registry/kv_storage_adapter.py
new file mode 100644
index 00000000..ae2bfd0d
--- /dev/null
+++ b/src/mcpstore/core/registry/kv_storage_adapter.py
@@ -0,0 +1,207 @@
+"""
+KV Storage Adapter for ServiceRegistry
+
+This module provides a clean abstraction layer for py-key-value storage operations,
+handling synchronization, collection naming, and value wrapping/unwrapping.
+
+Extracted from core_registry.py to reduce God Object complexity.
+"""
+
+import asyncio
+import logging
+from typing import Any, Dict, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from key_value.aio.protocols import AsyncKeyValue
+
+logger = logging.getLogger(__name__)
+
+
+class KVStorageAdapter:
+ """
+ Adapter for py-key-value storage operations.
+
+ 根据MCPStore核心架构原则重构:
+ - 提供同步和异步两个版本的API
+ - 同步方法使用asyncio.run()桥接
+ - 不再使用AsyncSyncHelper
+ """
+
+ def __init__(self, kv_store: 'AsyncKeyValue'):
+ """
+ Initialize KV storage adapter.
+
+ Args:
+ kv_store: AsyncKeyValue instance for data storage
+ """
+ self._kv_store = kv_store
+
+ def sync_to_kv(self, coro, operation_name: str = "KV operation"):
+ """
+ Synchronously execute an async KV store operation.
+
+ 根据MCPStore核心架构原则,使用asyncio.run()来桥接同步和异步代码。
+
+ Args:
+ coro: Coroutine to execute
+ operation_name: Description of the operation for logging
+ """
+ try:
+ logger.debug(f"[KV_SYNC] Starting sync: {operation_name}")
+ asyncio.run(coro)
+ logger.debug(f"[KV_SYNC] Successfully synced: {operation_name}")
+ except Exception as e:
+ # Treat KV sync failures as hard errors so they are not hidden
+ logger.error(
+ f"[KV_SYNC] Failed to sync to KV store: {operation_name}. Error: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def async_to_kv(self, coro, operation_name: str = "KV operation"):
+ """
+ Asynchronously execute an async KV store operation.
+
+ 异步版本的KV操作,直接await即可。
+
+ Args:
+ coro: Coroutine to execute
+ operation_name: Description of the operation for logging
+ """
+ try:
+ logger.debug(f"[KV_ASYNC] Starting async: {operation_name}")
+ await coro
+ logger.debug(f"[KV_ASYNC] Successfully completed: {operation_name}")
+ except Exception as e:
+ logger.error(
+ f"[KV_ASYNC] Failed to execute KV operation: {operation_name}. Error: {e}",
+ exc_info=True,
+ )
+ raise
+
+ def get_collection(self, agent_id: str, data_type: str) -> str:
+ """
+ 生成 Collection 名称(已废弃)。
+
+ 此方法使用旧的命名格式,已被新的三层缓存架构替代。
+ 不应再使用此方法。
+
+ Args:
+ agent_id: Agent 标识符
+ data_type: 数据类型
+
+ Raises:
+ NotImplementedError: 此方法已废弃
+ """
+ raise NotImplementedError(
+ "旧的 Collection 命名格式已废弃。请使用新的三层缓存架构:\n"
+ "- 实体层: {namespace}:entity:{entity_type}\n"
+ "- 关系层: {namespace}:relations:{relation_type}\n"
+ "- 状态层: {namespace}:state:{state_type}\n"
+ "请使用 CacheLayerManager 进行缓存操作。"
+ )
+
+ def wrap_scalar_value(self, value: Any) -> Dict[str, Any]:
+ """
+ Wrap a scalar value in a dictionary for py-key-value storage.
+
+ py-key-value expects dictionary values for storage. This method wraps
+ scalar values (strings, numbers, booleans, None) in a standard format.
+
+ Args:
+ value: Value to wrap (can be scalar or already a dict)
+
+ Returns:
+ Dictionary with "value" key containing the original value
+
+ Examples:
+ >>> adapter.wrap_scalar_value("healthy")
+ {"value": "healthy"}
+
+ >>> adapter.wrap_scalar_value(42)
+ {"value": 42}
+
+ >>> adapter.wrap_scalar_value({"already": "dict"})
+ {"already": "dict"} # Already a dict, returned as-is
+
+ Note:
+ - If value is already a dict, returns it unchanged
+ - This allows mixed storage of scalar and complex values
+ - Unwrap with unwrap_scalar_value() when reading
+ """
+ # If already a dict, return as-is (assume it's properly formatted)
+ if isinstance(value, dict):
+ return value
+
+ return {"value": value}
+
+ def unwrap_scalar_value(self, wrapped: Any) -> Any:
+ """
+ Unwrap a scalar value from dictionary storage format.
+
+ Reverses the wrapping done by wrap_scalar_value(). Handles both
+ wrapped scalar values and complex dictionary values.
+
+ Args:
+ wrapped: Value from KV storage (may be wrapped or complex dict)
+
+ Returns:
+ Original unwrapped value
+
+ Examples:
+ >>> adapter.unwrap_scalar_value({"value": "healthy"})
+ "healthy"
+
+ >>> adapter.unwrap_scalar_value({"value": 42})
+ 42
+
+ >>> adapter.unwrap_scalar_value({"complex": "dict", "with": "data"})
+ {"complex": "dict", "with": "data"} # Not wrapped, returned as-is
+
+ Note:
+ - If wrapped format detected (dict with single "value" key), unwraps it
+ - Otherwise returns the value unchanged
+ - Safe to call on any value from KV storage
+ """
+ # If it's a wrapped scalar (dict with single "value" key), unwrap it
+ if self.is_wrapped_value(wrapped):
+ return wrapped["value"]
+
+ # Otherwise return as-is (complex dict or other type)
+ return wrapped
+
+ def is_wrapped_value(self, value: Any) -> bool:
+ """
+ Check if a value is in wrapped format.
+
+ Determines if a value was wrapped by wrap_scalar_value() and needs
+ to be unwrapped when reading from storage.
+
+ Args:
+ value: Value to check
+
+ Returns:
+ True if value is a wrapped scalar, False otherwise
+
+ Examples:
+ >>> adapter.is_wrapped_value({"value": "healthy"})
+ True
+
+ >>> adapter.is_wrapped_value({"value": 42})
+ True
+
+ >>> adapter.is_wrapped_value({"complex": "dict"})
+ False
+
+ >>> adapter.is_wrapped_value("not a dict")
+ False
+
+ Note:
+ A value is considered wrapped if it's a dict with exactly one key "value"
+ """
+ return isinstance(value, dict) and "value" in value
+
+ @property
+ def kv_store(self) -> 'AsyncKeyValue':
+ """Get the underlying KV store instance."""
+ return self._kv_store
diff --git a/src/mcpstore/core/registry/kv_store_factory.py b/src/mcpstore/core/registry/kv_store_factory.py
new file mode 100644
index 00000000..74262b53
--- /dev/null
+++ b/src/mcpstore/core/registry/kv_store_factory.py
@@ -0,0 +1,258 @@
+"""
+Factory for building py-key-value store instances with wrapper chains.
+
+This module provides the _build_kv_store factory function that creates
+AsyncKeyValue instances with appropriate wrappers based on configuration.
+
+Validates:
+ - Requirements 2.1: Core advantages of py-key-value
+ - Requirements 17.1: Statistics wrapper configuration
+ - Requirements 17.2: Size limit wrapper configuration
+ - Requirements 17.3: Compression wrapper configuration
+ - Requirements 17.4: Wrapper combination
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+from .redis_config import RedisConfig
+from .wrapper_config import WrapperConfig
+
+if TYPE_CHECKING:
+ from key_value.aio.protocols import AsyncKeyValue
+
+logger = logging.getLogger(__name__)
+
+
+def _build_kv_store(config: Optional[Dict[str, Any]] = None) -> 'AsyncKeyValue':
+ """
+ Build a py-key-value store instance with wrapper chain.
+
+ This factory function creates an AsyncKeyValue instance based on configuration,
+ applying wrappers in the correct order:
+ 1. Base store (MemoryStore or RedisStore)
+ 2. LimitSizeWrapper (if enabled)
+ 3. CompressionWrapper (if enabled)
+ 4. StatisticsWrapper (if enabled)
+
+ Args:
+ config: Configuration dictionary with the following structure:
+ {
+ "type": "memory" | "redis", # Backend type (default: "memory")
+ "url": "redis://host:port/db", # Required for Redis
+ "password": "xxx", # Optional for Redis
+ "namespace": "myapp", # Optional namespace prefix
+
+ # Wrapper configuration
+ "enable_statistics": True, # Enable statistics wrapper (default: True)
+ "enable_size_limit": True, # Enable size limit wrapper (default: True)
+ "max_item_size": 1048576, # Max item size in bytes (default: 1MB)
+ "enable_compression": False, # Enable compression wrapper (default: False)
+ "compression_threshold": 524288, # Compression threshold in bytes (default: 512KB)
+ }
+
+ Returns:
+ AsyncKeyValue: Configured store instance with wrapper chain
+
+ Raises:
+ RuntimeError: If Redis backend is requested but connection fails
+ ImportError: If py-key-value is not installed
+
+ Examples:
+ >>> # Memory backend with default wrappers
+ >>> store = _build_kv_store({"type": "memory"})
+
+ >>> # Redis backend with all wrappers
+ >>> store = _build_kv_store({
+ ... "type": "redis",
+ ... "url": "redis://localhost:6379/0",
+ ... "enable_statistics": True,
+ ... "enable_size_limit": True,
+ ... "max_item_size": 1024 * 1024,
+ ... "enable_compression": True,
+ ... "compression_threshold": 512 * 1024
+ ... })
+
+ Note:
+ Wrapper order is important:
+ - Statistics wrapper is outermost (measures everything)
+ - Compression wrapper is in the middle (compresses before size check)
+ - LimitSize wrapper is innermost (validates final size)
+
+ Validates:
+ - Requirements 2.1: 开箱即用的企业级特性
+ - Requirements 17.1: 统计包装器配置
+ - Requirements 17.2: 大小限制包装器配置
+ - Requirements 17.3: 压缩包装器配置
+ - Requirements 17.4: 包装器组合
+ """
+ # Import py-key-value components
+ try:
+ from key_value.aio.stores.memory import MemoryStore
+ from key_value.aio.wrappers.statistics import StatisticsWrapper
+ from key_value.aio.wrappers.limit_size import LimitSizeWrapper
+ from key_value.aio.wrappers.compression import CompressionWrapper
+ except ImportError as e:
+ raise ImportError(
+ "py-key-value is not installed. Please install it with: "
+ "pip install py-key-value"
+ ) from e
+
+ # Default configuration
+ config = config or {}
+ backend_type = config.get("type", "memory")
+
+ # Parse wrapper configuration
+ wrapper_config = WrapperConfig.from_dict(config)
+
+ # Step 1: Create base store (Fail-Fast: no auto-degradation)
+ if backend_type == "redis":
+ # Redis backend: fail immediately if connection fails
+ # DO NOT auto-degrade to memory backend
+ base_store = _build_redis_store(config)
+ elif backend_type == "memory":
+ base_store = MemoryStore()
+ logger.debug("Created MemoryStore as base backend")
+ else:
+ # Unknown backend type: fail fast with clear error
+ raise ValueError(
+ f"Unknown backend type: '{backend_type}'. "
+ f"Supported types: 'memory', 'redis'"
+ )
+
+ # Step 2: Apply wrapper chain (from inner to outer)
+ store = base_store
+
+ # 2.1: LimitSizeWrapper (innermost - validates final size)
+ if wrapper_config.enable_size_limit:
+ store = LimitSizeWrapper(
+ key_value=store,
+ max_size=wrapper_config.max_item_size,
+ raise_on_too_large=False # Don't raise, just log warning
+ )
+ logger.debug(f"Applied LimitSizeWrapper: max_size={wrapper_config.max_item_size} bytes")
+
+ # 2.2: CompressionWrapper (middle - compresses large items)
+ if wrapper_config.enable_compression:
+ store = CompressionWrapper(
+ key_value=store,
+ min_size_to_compress=wrapper_config.compression_threshold
+ )
+ logger.debug(f"Applied CompressionWrapper: min_size_to_compress={wrapper_config.compression_threshold} bytes")
+
+ # 2.3: StatisticsWrapper (outermost - measures everything)
+ if wrapper_config.enable_statistics:
+ store = StatisticsWrapper(key_value=store)
+ logger.debug("Applied StatisticsWrapper")
+
+ logger.info(f"Built kv_store: type={backend_type}, wrappers={_get_wrapper_names(store)}")
+ return store
+
+
+def _build_redis_store(config: Dict[str, Any]) -> 'AsyncKeyValue':
+ """
+ Build a RedisStore instance from configuration.
+
+ This function uses RedisConfig to parse and validate the configuration,
+ then creates a RedisStore instance with the parsed parameters.
+
+ Args:
+ config: Redis configuration dictionary
+
+ Returns:
+ RedisStore instance
+
+ Raises:
+ RuntimeError: If Redis connection fails or configuration is invalid
+ ValueError: If configuration is invalid
+
+ Validates:
+ - Requirements 18.1: 基础连接配置
+ - Requirements 18.2: 连接池配置
+ """
+ try:
+ from key_value.aio.stores.redis import RedisStore
+ except ImportError as e:
+ raise ImportError(
+ "py-key-value Redis support is not installed. "
+ "Please install it with: pip install py-key-value[redis]"
+ ) from e
+
+ # Parse and validate Redis configuration
+ try:
+ redis_config = RedisConfig.from_dict(config)
+ except ValueError as e:
+ raise RuntimeError(
+ f"Invalid Redis configuration: {e}. "
+ f"Example: {{'type': 'redis', 'url': 'redis://localhost:6379/0'}}"
+ ) from e
+
+ # Build RedisStore with validated configuration
+ try:
+ redis_kwargs = redis_config.to_redis_kwargs()
+ store = RedisStore(**redis_kwargs)
+ logger.info(f"Created RedisStore: {redis_config}")
+
+ # Fail-Fast: Validate connection immediately with ping test
+ # This ensures we fail early if Redis is not accessible
+ try:
+ import asyncio
+ # Try to ping Redis to validate connection
+ # Note: This is a synchronous context, so we need to handle async carefully
+ # The actual ping will happen on first use, but we validate the store was created
+ logger.debug("RedisStore created successfully, connection will be validated on first use")
+ except Exception as ping_error:
+ logger.warning(f"Redis connection validation warning: {ping_error}")
+ # Continue anyway - connection will be validated on first actual use
+
+ return store
+
+ except Exception as e:
+ # Fail-Fast: Provide clear, actionable error message
+ error_msg = (
+ f"Failed to create RedisStore: {e}\n"
+ f"Configuration: {redis_config}\n"
+ f"Troubleshooting steps:\n"
+ f" 1. Verify Redis server is running\n"
+ f" 2. Check URL is correct: {redis_config.url}\n"
+ f" 3. Verify network connectivity to Redis host\n"
+ f" 4. Check Redis password (if required)\n"
+ f" 5. Ensure Redis is accepting connections on the specified port\n"
+ f"\n"
+ f"Note: MCPStore will NOT auto-degrade to memory backend. "
+ f"Redis connection must succeed."
+ )
+ logger.error(error_msg)
+ raise RuntimeError(error_msg) from e
+
+
+def _get_wrapper_names(store: 'AsyncKeyValue') -> str:
+ """
+ Get a string representation of the wrapper chain.
+
+ Args:
+ store: AsyncKeyValue instance (possibly wrapped)
+
+ Returns:
+ Comma-separated list of wrapper names
+ """
+ wrappers = []
+ current = store
+
+ # Walk the wrapper chain
+ while hasattr(current, '__class__'):
+ class_name = current.__class__.__name__
+ if class_name != 'MemoryStore' and class_name != 'RedisStore':
+ wrappers.append(class_name)
+
+ # Try to get the wrapped store
+ if hasattr(current, 'key_value'):
+ current = current.key_value
+ elif hasattr(current, '_key_value'):
+ current = current._key_value
+ else:
+ break
+
+ return ', '.join(wrappers) if wrappers else 'none'
diff --git a/src/mcpstore/core/registry/normalizer.py b/src/mcpstore/core/registry/normalizer.py
new file mode 100644
index 00000000..4675f8e4
--- /dev/null
+++ b/src/mcpstore/core/registry/normalizer.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from typing import Protocol, Dict, Any
+
+
+class ToolNormalizer(Protocol):
+ """Normalizes tool definitions for backend storage (JSON-compatible).
+
+ Implementations should:
+ - Remove non-serializable objects
+ - Keep stable fields: name, description, parameters, service_name, etc.
+ - Optionally compact or canonicalize ordering
+ """
+
+ def normalize_tool(self, tool_name: str, tool_def: Dict[str, Any]) -> Dict[str, Any]:
+ ...
+
+
+class DefaultToolNormalizer:
+ def normalize_tool(self, tool_name: str, tool_def: Dict[str, Any]) -> Dict[str, Any]:
+ # Shallow best-effort normalization for skeleton stage
+ out: Dict[str, Any] = {}
+ if isinstance(tool_def, dict):
+ if "function" in tool_def and isinstance(tool_def["function"], dict):
+ fn = tool_def["function"]
+ out["type"] = "function"
+ out_fn = {
+ "name": fn.get("name", tool_name),
+ "description": fn.get("description", ""),
+ "service_name": fn.get("service_name", ""),
+ "parameters": fn.get("parameters"),
+ }
+ out["function"] = out_fn
+ else:
+ # Fallback mapping
+ out.update({
+ "name": tool_def.get("name", tool_name),
+ "description": tool_def.get("description", ""),
+ "service_name": tool_def.get("service_name", ""),
+ "parameters": tool_def.get("parameters"),
+ })
+ else:
+ out = {"name": tool_name, "description": str(tool_def)}
+ return out
+
diff --git a/src/mcpstore/core/registry/redis_atomic.py b/src/mcpstore/core/registry/redis_atomic.py
new file mode 100644
index 00000000..4a804815
--- /dev/null
+++ b/src/mcpstore/core/registry/redis_atomic.py
@@ -0,0 +1,92 @@
+"""Redis atomic operations using Lua scripts for data consistency."""
+from __future__ import annotations
+
+import json
+import logging
+from typing import Dict, Any
+
+logger = logging.getLogger(__name__)
+
+
+class RedisAtomicOps:
+ """Provides atomic operations on Redis using Lua scripts.
+
+ This ensures data consistency for Read-Modify-Write operations
+ that would otherwise be subject to race conditions.
+ """
+
+ # Lua script: Atomic JSON update (Read-Modify-Write)
+ LUA_UPDATE_JSON = """
+ local key = KEYS[1]
+ local updates_json = ARGV[1]
+ local updates = cjson.decode(updates_json)
+
+ -- Read current value
+ local current_json = redis.call('GET', key)
+ local config = {}
+ if current_json then
+ config = cjson.decode(current_json)
+ end
+
+ -- Merge updates
+ for k, v in pairs(updates) do
+ config[k] = v
+ end
+
+ -- Write back atomically
+ redis.call('SET', key, cjson.encode(config))
+ return 1
+ """
+
+ def __init__(self, redis_client):
+ """Initialize with a Redis client.
+
+ Args:
+ redis_client: redis.Redis instance
+ """
+ self._redis = redis_client
+ # Pre-load scripts for better performance
+ try:
+ self._update_json_sha = self._redis.script_load(self.LUA_UPDATE_JSON)
+ logger.debug("Redis Lua scripts loaded successfully")
+ except Exception as e:
+ logger.warning(f"Failed to preload Lua scripts: {e}. Will load on demand.")
+ self._update_json_sha = None
+
+ def update_json_atomic(self, key: str, updates: Dict[str, Any]) -> bool:
+ """Atomically update a JSON object stored in Redis.
+
+ This performs a Read-Modify-Write operation atomically:
+ 1. Read current JSON
+ 2. Merge with updates
+ 3. Write back
+
+ Args:
+ key: Redis key
+ updates: Dictionary of updates to merge
+
+ Returns:
+ True if successful
+
+ Example:
+ >>> ops.update_json_atomic("config:123", {"timeout": 30})
+ """
+ try:
+ updates_json = json.dumps(updates, ensure_ascii=False)
+
+ # Try using preloaded script
+ if self._update_json_sha:
+ try:
+ self._redis.evalsha(self._update_json_sha, 1, key, updates_json)
+ return True
+ except Exception:
+ # Script not found, reload
+ logger.debug("Reloading Lua script (SHA not found)")
+
+ # Fallback: load and execute
+ self._redis.eval(self.LUA_UPDATE_JSON, 1, key, updates_json)
+ return True
+
+ except Exception as e:
+ logger.error(f"Atomic JSON update failed for key {key}: {e}")
+ return False
diff --git a/src/mcpstore/core/registry/redis_config.py b/src/mcpstore/core/registry/redis_config.py
new file mode 100644
index 00000000..79d808e0
--- /dev/null
+++ b/src/mcpstore/core/registry/redis_config.py
@@ -0,0 +1,304 @@
+"""
+Redis configuration parser and validator.
+
+This module provides utilities for parsing and validating Redis connection
+configuration from user-provided config dictionaries.
+
+Validates:
+ - Requirements 18.1: Basic connection configuration
+ - Requirements 18.2: Connection pool configuration
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, Optional
+
+from ...config.config_defaults import CacheRedisConfigDefaults
+
+logger = logging.getLogger(__name__)
+
+_redis_defaults = CacheRedisConfigDefaults()
+
+
+class RedisConfig:
+ """
+ Parsed and validated Redis configuration.
+
+ This class encapsulates all Redis connection-related configuration options,
+ providing defaults and validation.
+
+ Attributes:
+ url: Redis connection URL (required)
+ password: Redis password (optional)
+ socket_timeout: Socket timeout in seconds
+ socket_connect_timeout: Connection timeout in seconds
+ max_connections: Maximum number of connections in pool
+ healthcheck_interval: Health check interval in seconds
+
+ Validates:
+ - Requirements 18.1: Basic connection configuration
+ - Requirements 18.2: Connection pool configuration
+ """
+
+ # Default values
+ DEFAULT_SOCKET_TIMEOUT = _redis_defaults.socket_timeout # seconds
+ DEFAULT_SOCKET_CONNECT_TIMEOUT = _redis_defaults.socket_connect_timeout # seconds
+ DEFAULT_MAX_CONNECTIONS = _redis_defaults.max_connections
+ DEFAULT_HEALTHCHECK_INTERVAL = _redis_defaults.health_check_interval # seconds
+
+ def __init__(
+ self,
+ url: str,
+ password: Optional[str] = None,
+ socket_timeout: float = DEFAULT_SOCKET_TIMEOUT,
+ socket_connect_timeout: float = DEFAULT_SOCKET_CONNECT_TIMEOUT,
+ max_connections: int = DEFAULT_MAX_CONNECTIONS,
+ healthcheck_interval: int = DEFAULT_HEALTHCHECK_INTERVAL
+ ):
+ """
+ Initialize Redis configuration.
+
+ Args:
+ url: Redis connection URL (e.g., "redis://localhost:6379/0")
+ password: Redis password (optional)
+ socket_timeout: Socket timeout in seconds
+ socket_connect_timeout: Connection timeout in seconds
+ max_connections: Maximum number of connections in pool
+ healthcheck_interval: Health check interval in seconds
+
+ Raises:
+ ValueError: If configuration is invalid
+ """
+ self.url = url
+ self.password = password
+ self.socket_timeout = socket_timeout
+ self.socket_connect_timeout = socket_connect_timeout
+ self.max_connections = max_connections
+ self.healthcheck_interval = healthcheck_interval
+
+ # Validate configuration
+ self._validate()
+
+ def _validate(self) -> None:
+ """
+ Validate configuration values.
+
+ Raises:
+ ValueError: If configuration is invalid
+ """
+ # Validate URL
+ if not self.url:
+ raise ValueError("Redis URL is required")
+
+ if not isinstance(self.url, str):
+ raise ValueError(f"Redis URL must be a string, got: {type(self.url)}")
+
+ # Basic URL format validation
+ if not (self.url.startswith("redis://") or self.url.startswith("rediss://")):
+ raise ValueError(
+ f"Redis URL must start with 'redis://' or 'rediss://', got: {self.url}"
+ )
+
+ # Validate socket_timeout
+ if not isinstance(self.socket_timeout, (int, float)) or self.socket_timeout <= 0:
+ raise ValueError(
+ f"socket_timeout must be a positive number, got: {self.socket_timeout}"
+ )
+
+ # Validate socket_connect_timeout
+ if not isinstance(self.socket_connect_timeout, (int, float)) or self.socket_connect_timeout <= 0:
+ raise ValueError(
+ f"socket_connect_timeout must be a positive number, got: {self.socket_connect_timeout}"
+ )
+
+ # Validate max_connections
+ if not isinstance(self.max_connections, int) or self.max_connections <= 0:
+ raise ValueError(
+ f"max_connections must be a positive integer, got: {self.max_connections}"
+ )
+
+ # Warn if max_connections is too small
+ if self.max_connections < 5:
+ logger.warning(
+ f"max_connections is very small ({self.max_connections}). "
+ f"This may cause connection pool exhaustion under load."
+ )
+
+ # Validate healthcheck_interval
+ if not isinstance(self.healthcheck_interval, (int, float)) or self.healthcheck_interval <= 0:
+ raise ValueError(
+ f"healthcheck_interval must be a positive number, got: {self.healthcheck_interval}"
+ )
+
+ @classmethod
+ def from_dict(cls, config: Dict[str, Any]) -> 'RedisConfig':
+ """
+ Parse Redis configuration from a dictionary.
+
+ Args:
+ config: Configuration dictionary with keys:
+ - url: str (required) - Redis connection URL
+ - password: str (optional) - Redis password
+ - socket_timeout: float (optional, default: 2.0) - Socket timeout
+ - socket_connect_timeout: float (optional, default: 2.0) - Connect timeout
+ - max_connections: int (optional, default: 50) - Max connections
+ - healthcheck_interval: int (optional, default: 30) - Health check interval
+
+ Returns:
+ RedisConfig instance with parsed values
+
+ Raises:
+ ValueError: If required fields are missing or invalid
+
+ Examples:
+ >>> # Minimal configuration
+ >>> config = RedisConfig.from_dict({
+ ... "url": "redis://localhost:6379/0"
+ ... })
+
+ >>> # Full configuration
+ >>> config = RedisConfig.from_dict({
+ ... "url": "redis://prod-redis:6379/0",
+ ... "password": "secret",
+ ... "socket_timeout": 5.0,
+ ... "socket_connect_timeout": 3.0,
+ ... "max_connections": 100,
+ ... "healthcheck_interval": 60
+ ... })
+
+ Validates:
+ - Requirements 18.1: Parse basic configuration such as URL, password, timeout
+ - Requirements 18.2: Parse connection pool configuration
+ """
+ # Validate required fields
+ url = config.get("url")
+ if not url:
+ raise ValueError(
+ "Redis configuration requires 'url' field. "
+ "Example: {'url': 'redis://localhost:6379/0'}"
+ )
+
+ # Parse optional fields with defaults
+ # Use 'or' to handle both missing keys and explicit None values
+ password = config.get("password")
+ socket_timeout = config.get("socket_timeout") or cls.DEFAULT_SOCKET_TIMEOUT
+ socket_connect_timeout = config.get("socket_connect_timeout") or cls.DEFAULT_SOCKET_CONNECT_TIMEOUT
+ max_connections = config.get("max_connections") or cls.DEFAULT_MAX_CONNECTIONS
+ healthcheck_interval = config.get("healthcheck_interval") or cls.DEFAULT_HEALTHCHECK_INTERVAL
+
+ # Type coercion for robustness
+ try:
+ socket_timeout = float(socket_timeout)
+ socket_connect_timeout = float(socket_connect_timeout)
+ max_connections = int(max_connections)
+ healthcheck_interval = float(healthcheck_interval)
+ except (TypeError, ValueError) as e:
+ raise ValueError(
+ f"Invalid Redis configuration: {e}. "
+ f"Example: {{'type': 'redis', 'url': 'redis://localhost:6379/0'}}"
+ ) from e
+
+ logger.debug(
+ f"Parsed Redis config: url={url}, "
+ f"socket_timeout={socket_timeout}s, "
+ f"socket_connect_timeout={socket_connect_timeout}s, "
+ f"max_connections={max_connections}, "
+ f"healthcheck_interval={healthcheck_interval}s"
+ )
+
+ return cls(
+ url=url,
+ password=password,
+ socket_timeout=socket_timeout,
+ socket_connect_timeout=socket_connect_timeout,
+ max_connections=max_connections,
+ healthcheck_interval=healthcheck_interval
+ )
+
+ def to_dict(self) -> Dict[str, Any]:
+ """
+ Convert configuration to dictionary.
+
+ Returns:
+ Dictionary representation of configuration
+ """
+ result = {
+ "url": self.url,
+ "socket_timeout": self.socket_timeout,
+ "socket_connect_timeout": self.socket_connect_timeout,
+ "max_connections": self.max_connections,
+ "healthcheck_interval": self.healthcheck_interval
+ }
+
+ # Only include password if set
+ if self.password:
+ result["password"] = self.password
+
+ return result
+
+ def to_redis_kwargs(self) -> Dict[str, Any]:
+ """
+ Convert configuration to kwargs for py-key-value RedisStore constructor.
+
+ Note:
+ py-key-value RedisStore only accepts: url, host, port, db, password, client, default_collection
+ Connection pool and timeout settings are not directly supported by py-key-value RedisStore.
+ These settings are stored in this config for potential future use or custom client creation.
+
+ Returns:
+ Dictionary of kwargs suitable for RedisStore(**kwargs)
+ """
+ kwargs = {
+ "url": self.url,
+ }
+
+ # Only include password if set
+ if self.password:
+ kwargs["password"] = self.password
+
+ return kwargs
+
+ def __repr__(self) -> str:
+ """String representation of configuration."""
+ # Mask password for security
+ password_display = "***" if self.password else "None"
+ return (
+ f"RedisConfig("
+ f"url={self.url}, "
+ f"password={password_display}, "
+ f"socket_timeout={self.socket_timeout}s, "
+ f"socket_connect_timeout={self.socket_connect_timeout}s, "
+ f"max_connections={self.max_connections}, "
+ f"healthcheck_interval={self.healthcheck_interval}s)"
+ )
+
+
+def parse_redis_config(config: Dict[str, Any]) -> RedisConfig:
+ """
+ Parse Redis configuration from a dictionary.
+
+ This is a convenience function that delegates to RedisConfig.from_dict().
+
+ Args:
+ config: Configuration dictionary
+
+ Returns:
+ RedisConfig instance
+
+ Raises:
+ ValueError: If configuration is invalid
+
+ Examples:
+ >>> config = parse_redis_config({
+ ... "url": "redis://localhost:6379/0",
+ ... "password": "secret"
+ ... })
+ >>> print(config.url)
+ redis://localhost:6379/0
+
+ Validates:
+ - Requirements 18.1: Parse basic configuration such as URL, password, timeout
+ - Requirements 18.2: Parse connection pool configuration
+ """
+ return RedisConfig.from_dict(config)
diff --git a/src/mcpstore/core/registry/redis_health.py b/src/mcpstore/core/registry/redis_health.py
new file mode 100644
index 00000000..e9cfae5e
--- /dev/null
+++ b/src/mcpstore/core/registry/redis_health.py
@@ -0,0 +1,425 @@
+"""
+Redis health checking and connection validation.
+
+This module provides utilities for validating Redis connections and
+implementing health checks with automatic reconnection.
+
+Validates:
+ - Requirements 18.3: Fault handling configuration
+ - Requirements 18.4: Fail-Fast error handling
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from key_value.aio.stores.redis import RedisStore
+
+logger = logging.getLogger(__name__)
+
+
+class RedisConnectionError(Exception):
+ """
+ Exception raised when Redis connection fails.
+
+ This exception is raised during fail-fast validation to provide
+ clear error messages about connection failures.
+
+ Validates:
+ - Requirements 18.4: Fail-Fast error handling
+ """
+ pass
+
+
+async def validate_redis_connection(
+ store: 'RedisStore',
+ timeout: float = 5.0
+) -> None:
+ """
+ Validate Redis connection with fail-fast behavior.
+
+ This function attempts to ping the Redis server to ensure the connection
+ is working. If the connection fails, it raises RedisConnectionError with
+ a clear error message.
+
+ Args:
+ store: RedisStore instance to validate
+ timeout: Timeout in seconds for the ping operation
+
+ Raises:
+ RedisConnectionError: If connection validation fails
+ asyncio.TimeoutError: If ping operation times out
+
+ Examples:
+ >>> store = RedisStore(url="redis://localhost:6379/0")
+ >>> await validate_redis_connection(store)
+ # Raises RedisConnectionError if connection fails
+
+ Validates:
+ - Requirements 18.4: Throw exception immediately when Redis connection fails
+ """
+ try:
+ # Attempt to ping Redis with timeout
+ await asyncio.wait_for(
+ _ping_redis(store),
+ timeout=timeout
+ )
+ logger.info("Redis connection validated successfully")
+
+ except asyncio.TimeoutError as e:
+ error_msg = (
+ f"Redis connection validation timed out after {timeout}s. "
+ f"Redis server may be unresponsive or network is slow."
+ )
+ logger.error(error_msg)
+ raise RedisConnectionError(error_msg) from e
+
+ except Exception as e:
+ error_msg = (
+ f"Redis connection validation failed: {e}. "
+ f"Please verify Redis server is running and accessible."
+ )
+ logger.error(error_msg)
+ raise RedisConnectionError(error_msg) from e
+
+
+async def _ping_redis(store: 'RedisStore') -> None:
+ """
+ Internal function to ping Redis.
+
+ This attempts a simple operation to verify the connection works.
+
+ Args:
+ store: RedisStore instance
+
+ Raises:
+ Exception: If ping fails
+ """
+ # Try a simple operation to validate connection
+ # We'll try to get a non-existent key, which should return None
+ test_key = f"__mcpstore_health_check_{time.time()}"
+ try:
+ await store.get(test_key)
+ logger.debug("Redis ping successful")
+ except Exception as e:
+ logger.error(f"Redis ping failed: {e}")
+ raise
+
+
+class RedisHealthChecker:
+ """
+ Health checker for Redis connections with automatic reconnection.
+
+ This class provides periodic health checks and automatic reconnection
+ with exponential backoff when Redis connection fails.
+
+ Attributes:
+ store: RedisStore instance to monitor
+ check_interval: Interval between health checks in seconds
+ max_retries: Maximum number of reconnection attempts
+ backoff_factor: Exponential backoff factor for retries
+
+ Validates:
+ - Requirements 18.3: Periodic health checks
+ - Requirements 18.3: Automatic reconnection (with backoff strategy)
+ """
+
+ def __init__(
+ self,
+ store: 'RedisStore',
+ check_interval: float = 30.0,
+ max_retries: int = 5,
+ backoff_factor: float = 2.0
+ ):
+ """
+ Initialize health checker.
+
+ Args:
+ store: RedisStore instance to monitor
+ check_interval: Interval between health checks in seconds
+ max_retries: Maximum number of reconnection attempts
+ backoff_factor: Exponential backoff factor for retries
+ """
+ self.store = store
+ self.check_interval = check_interval
+ self.max_retries = max_retries
+ self.backoff_factor = backoff_factor
+
+ self._is_healthy = True
+ self._consecutive_failures = 0
+ self._last_check_time = 0.0
+ self._health_check_task: Optional[asyncio.Task] = None
+
+ async def start(self) -> None:
+ """
+ Start periodic health checks.
+
+ This starts a background task that periodically checks Redis health
+ and attempts reconnection if needed.
+
+ Validates:
+ - Requirements 18.3: Periodic health checks
+ """
+ if self._health_check_task is not None:
+ logger.warning("Health checker already started")
+ return
+
+ self._health_check_task = asyncio.create_task(self._health_check_loop())
+ logger.info(f"Started Redis health checker: interval={self.check_interval}s")
+
+ async def stop(self) -> None:
+ """
+ Stop periodic health checks.
+ """
+ if self._health_check_task is None:
+ return
+
+ self._health_check_task.cancel()
+ try:
+ await self._health_check_task
+ except asyncio.CancelledError:
+ pass
+
+ self._health_check_task = None
+ logger.info("Stopped Redis health checker")
+
+ async def _health_check_loop(self) -> None:
+ """
+ Main health check loop.
+
+ This runs continuously, checking Redis health at regular intervals.
+ """
+ while True:
+ try:
+ await asyncio.sleep(self.check_interval)
+ await self._perform_health_check()
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Health check loop error: {e}")
+
+ async def _perform_health_check(self) -> None:
+ """
+ Perform a single health check.
+
+ This pings Redis and updates health status. If the check fails,
+ it attempts reconnection with exponential backoff.
+
+ Validates:
+ - Requirements 18.3: Automatic reconnection (with backoff strategy)
+ """
+ self._last_check_time = time.time()
+
+ try:
+ # Attempt to ping Redis
+ await _ping_redis(self.store)
+
+ # Success: reset failure counter
+ if not self._is_healthy:
+ logger.info("Redis connection recovered")
+
+ self._is_healthy = True
+ self._consecutive_failures = 0
+
+ except Exception as e:
+ # Failure: increment counter and attempt reconnection
+ self._consecutive_failures += 1
+ self._is_healthy = False
+
+ logger.warning(
+ f"Redis health check failed (attempt {self._consecutive_failures}/{self.max_retries}): {e}"
+ )
+
+ # Attempt reconnection with exponential backoff
+ if self._consecutive_failures <= self.max_retries:
+ await self._attempt_reconnection()
+ else:
+ logger.error(
+ f"Redis connection failed after {self.max_retries} attempts. "
+ f"Giving up on automatic reconnection."
+ )
+
+ async def _attempt_reconnection(self) -> None:
+ """
+ Attempt to reconnect to Redis with exponential backoff.
+
+ Validates:
+ - Requirements 18.3: Automatic reconnection (with backoff strategy)
+ """
+ # Calculate backoff delay
+ delay = min(
+ self.check_interval * (self.backoff_factor ** (self._consecutive_failures - 1)),
+ 300.0 # Max 5 minutes
+ )
+
+ logger.info(f"Attempting Redis reconnection in {delay:.1f}s...")
+ await asyncio.sleep(delay)
+
+ try:
+ # Try to ping Redis
+ await _ping_redis(self.store)
+ logger.info("Redis reconnection successful")
+ self._is_healthy = True
+ self._consecutive_failures = 0
+
+ except Exception as e:
+ logger.warning(f"Redis reconnection failed: {e}")
+
+ @property
+ def is_healthy(self) -> bool:
+ """
+ Check if Redis connection is currently healthy.
+
+ Returns:
+ True if healthy, False otherwise
+ """
+ return self._is_healthy
+
+ @property
+ def consecutive_failures(self) -> int:
+ """
+ Get the number of consecutive health check failures.
+
+ Returns:
+ Number of consecutive failures
+ """
+ return self._consecutive_failures
+
+
+class RedisCircuitBreaker:
+ """
+ Circuit breaker for Redis operations.
+
+ This implements a circuit breaker pattern to prevent cascading failures
+ when Redis is unavailable. The circuit breaker has three states:
+ - CLOSED: Normal operation, requests pass through
+ - OPEN: Redis is failing, requests are rejected immediately
+ - HALF_OPEN: Testing if Redis has recovered
+
+ Validates:
+ - Requirements 18.3: Circuit breaker mechanism
+ """
+
+ # Circuit breaker states
+ STATE_CLOSED = "CLOSED"
+ STATE_OPEN = "OPEN"
+ STATE_HALF_OPEN = "HALF_OPEN"
+
+ def __init__(
+ self,
+ failure_threshold: int = 5,
+ recovery_timeout: float = 60.0,
+ half_open_max_calls: int = 3
+ ):
+ """
+ Initialize circuit breaker.
+
+ Args:
+ failure_threshold: Number of failures before opening circuit
+ recovery_timeout: Time to wait before attempting recovery (seconds)
+ half_open_max_calls: Max calls to allow in half-open state
+ """
+ self.failure_threshold = failure_threshold
+ self.recovery_timeout = recovery_timeout
+ self.half_open_max_calls = half_open_max_calls
+
+ self._state = self.STATE_CLOSED
+ self._failure_count = 0
+ self._last_failure_time = 0.0
+ self._half_open_calls = 0
+
+ async def call(self, func, *args, **kwargs):
+ """
+ Execute a function through the circuit breaker.
+
+ Args:
+ func: Async function to execute
+ *args: Positional arguments for func
+ **kwargs: Keyword arguments for func
+
+ Returns:
+ Result of func
+
+ Raises:
+ RuntimeError: If circuit is open
+ Exception: Any exception raised by func
+
+ Validates:
+ - Requirements 18.3: Circuit breaker mechanism
+ """
+ # Check if circuit should transition to half-open
+ if self._state == self.STATE_OPEN:
+ if time.time() - self._last_failure_time >= self.recovery_timeout:
+ logger.info("Circuit breaker transitioning to HALF_OPEN")
+ self._state = self.STATE_HALF_OPEN
+ self._half_open_calls = 0
+ else:
+ raise RuntimeError(
+ f"Circuit breaker is OPEN. Redis operations are blocked. "
+ f"Will retry in {self.recovery_timeout - (time.time() - self._last_failure_time):.1f}s"
+ )
+
+ # Reject calls in half-open state if limit reached
+ if self._state == self.STATE_HALF_OPEN:
+ if self._half_open_calls >= self.half_open_max_calls:
+ raise RuntimeError(
+ "Circuit breaker is HALF_OPEN and call limit reached. "
+ "Waiting for test calls to complete."
+ )
+ self._half_open_calls += 1
+
+ # Execute the function
+ try:
+ result = await func(*args, **kwargs)
+
+ # Success: reset or close circuit
+ if self._state == self.STATE_HALF_OPEN:
+ logger.info("Circuit breaker transitioning to CLOSED (recovery successful)")
+ self._state = self.STATE_CLOSED
+ self._failure_count = 0
+ self._half_open_calls = 0
+ elif self._state == self.STATE_CLOSED:
+ self._failure_count = 0
+
+ return result
+
+ except Exception as e:
+ # Failure: increment counter and potentially open circuit
+ self._failure_count += 1
+ self._last_failure_time = time.time()
+
+ if self._state == self.STATE_HALF_OPEN:
+ logger.warning("Circuit breaker transitioning to OPEN (recovery failed)")
+ self._state = self.STATE_OPEN
+ self._half_open_calls = 0
+ elif self._failure_count >= self.failure_threshold:
+ logger.error(
+ f"Circuit breaker transitioning to OPEN "
+ f"(failure threshold {self.failure_threshold} reached)"
+ )
+ self._state = self.STATE_OPEN
+
+ raise
+
+ @property
+ def state(self) -> str:
+ """
+ Get current circuit breaker state.
+
+ Returns:
+ Current state (CLOSED, OPEN, or HALF_OPEN)
+ """
+ return self._state
+
+ def reset(self) -> None:
+ """
+ Manually reset the circuit breaker to CLOSED state.
+ """
+ logger.info("Circuit breaker manually reset to CLOSED")
+ self._state = self.STATE_CLOSED
+ self._failure_count = 0
+ self._half_open_calls = 0
diff --git a/src/mcpstore/core/registry/registry_factory.py b/src/mcpstore/core/registry/registry_factory.py
new file mode 100644
index 00000000..04fdcc82
--- /dev/null
+++ b/src/mcpstore/core/registry/registry_factory.py
@@ -0,0 +1,160 @@
+"""
+Registry Factory - 简化工厂模式实现
+通过工厂模式创建服务注册表
+
+这个工厂利用现有的kv_store_factory模式,提供统一的服务创建接口。
+ServiceRegistry 使用新的三层缓存架构,内部自己创建所有管理器。
+"""
+
+import logging
+from abc import ABC, abstractmethod
+from typing import Dict, Any, Optional
+
+from .core_registry import ServiceRegistry
+from .kv_store_factory import _build_kv_store
+
+logger = logging.getLogger(__name__)
+
+
+class RegistryFactoryInterface(ABC):
+ """注册表工厂接口 - 定义统一创建接口"""
+
+ @abstractmethod
+ def create_service_registry(self, kv_store) -> 'ServiceRegistry':
+ """创建服务注册表"""
+ pass
+
+
+class ProductionRegistryFactory(RegistryFactoryInterface):
+ """
+ 生产级注册表工厂 - 简化实现
+
+ 特点:
+ - ServiceRegistry 使用新的三层缓存架构
+ - ServiceRegistry 内部自己创建所有管理器(CacheLayerManager、NamingService 等)
+ - 工厂只负责传递 kv_store
+ """
+
+ @staticmethod
+ def create_service_registry(kv_store, namespace: str = "mcpstore") -> 'ServiceRegistry':
+ """
+ 通过工厂模式创建ServiceRegistry实例
+
+ Args:
+ kv_store: 键值存储实例(由kv_store_factory创建)
+ namespace: 缓存命名空间(默认: "mcpstore")
+
+ Returns:
+ ServiceRegistry: 配置完成的注册表实例
+
+ Raises:
+ RuntimeError: 如果服务创建失败
+ """
+ try:
+ logger.debug("Creating ServiceRegistry with new cache architecture")
+
+ # ServiceRegistry 使用新的三层缓存架构
+ # 内部会自动创建:
+ # - CacheLayerManager
+ # - NamingService
+ # - ServiceEntityManager
+ # - ToolEntityManager
+ # - RelationshipManager
+ # - StateManager
+ registry = ServiceRegistry(
+ kv_store=kv_store,
+ namespace=namespace
+ )
+
+ logger.info("ServiceRegistry created via factory pattern (new cache architecture)")
+ return registry
+
+ except Exception as e:
+ logger.error(f"Failed to create ServiceRegistry via factory: {e}")
+ raise RuntimeError(f"Registry creation failed: {e}") from e
+
+ @staticmethod
+ def create_from_config(config: Optional[Dict[str, Any]] = None) -> 'ServiceRegistry':
+ """
+ 从配置创建注册表
+
+ Args:
+ config: 配置字典
+
+ Returns:
+ ServiceRegistry: 配置完成的注册表实例
+ """
+ # 使用现有的kv_store_factory创建存储后端
+ kv_store = _build_kv_store(config)
+
+ # 委托给主工厂方法
+ return ProductionRegistryFactory.create_service_registry(kv_store)
+
+
+class TestRegistryFactory(RegistryFactoryInterface):
+ """测试用注册表工厂 - 简化实现"""
+
+ def __init__(self, namespace: str = "test"):
+ self.namespace = namespace
+
+ def create_service_registry(self, kv_store) -> 'ServiceRegistry':
+ """创建测试用注册表"""
+ logger.debug("Creating ServiceRegistry for testing")
+
+ # ServiceRegistry 使用新的三层缓存架构
+ # 测试时使用 "test" 命名空间隔离数据
+ return ServiceRegistry(
+ kv_store=kv_store,
+ namespace=self.namespace
+ )
+
+
+# 公共工厂接口
+def create_registry_from_config(config: Optional[Dict[str, Any]] = None,
+ test_mode: bool = False) -> 'ServiceRegistry':
+ """
+ 创建注册表的公共接口
+
+ Args:
+ config: 配置字典
+ test_mode: 是否使用测试工厂
+
+ Returns:
+ ServiceRegistry: 创建的注册表实例
+ """
+ if test_mode:
+ return TestRegistryFactory().create_service_registry(None) # kv_store在测试中被mock
+ else:
+ return ProductionRegistryFactory.create_from_config(config)
+
+
+def create_registry_from_kv_store(kv_store, test_mode: bool = False, namespace: str = "mcpstore") -> 'ServiceRegistry':
+ """
+ 从KV存储创建注册表
+
+ Args:
+ kv_store: KV存储实例
+ test_mode: 是否使用测试工厂
+ namespace: 缓存命名空间(默认: "mcpstore")
+
+ Returns:
+ ServiceRegistry: 创建的注册表实例
+ """
+ if test_mode:
+ return TestRegistryFactory(namespace=namespace).create_service_registry(kv_store)
+ else:
+ return ProductionRegistryFactory.create_service_registry(kv_store, namespace=namespace)
+
+
+# 向后兼容的工厂函数
+def create_service_registry(kv_store) -> 'ServiceRegistry':
+ """
+ 向后兼容的工厂函数
+
+ Args:
+ kv_store: KV存储实例
+
+ Returns:
+ ServiceRegistry: 创建的注册表实例
+ """
+ return ProductionRegistryFactory.create_service_registry(kv_store)
diff --git a/src/mcpstore/core/registry/repository.py b/src/mcpstore/core/registry/repository.py
new file mode 100644
index 00000000..d1348514
--- /dev/null
+++ b/src/mcpstore/core/registry/repository.py
@@ -0,0 +1,116 @@
+"""
+Repository-style thin facade for cache operations.
+
+Goals:
+- Provide a small, cohesive API that wraps CacheBackend writes in atomic transactions
+- Offer methods suitable for multi-key write sequences (e.g., registering service tools)
+- Keep domain logic in ServiceRegistry, but enable reuse in orchestrators or tests
+
+This repository expects `registry` to be a ServiceRegistry-like object that exposes
+`cache_backend` for storage operations.
+"""
+from __future__ import annotations
+
+from typing import Dict, Iterable, Optional, Any
+
+from .atomic import atomic_write
+
+
+class CacheRepository:
+ """Thin facade over the cache backend with atomic write helpers.
+
+ Typical usage:
+ repo = CacheRepository(registry)
+ await repo.apply_service_snapshot(agent_id, service_name, client_id, tools_dict)
+ """
+
+ def __init__(self, registry: Any) -> None:
+ self.registry = registry
+ # Provide direct field so @atomic_write can resolve backend quickly
+ self.cache_backend = getattr(registry, "cache_backend")
+
+ # ----------------------- Bulk / Composite operations -----------------------
+
+ @atomic_write(agent_id_param="agent_id", use_lock=True)
+ async def apply_service_snapshot(
+ self,
+ agent_id: str,
+ service_name: str,
+ client_id: str,
+ tools: Dict[str, Dict[str, Any]],
+ ) -> None:
+ """Apply a full set of tool mappings and definitions for one service.
+
+ - Maps each tool to the service
+ - Upserts each tool's definition (normalized by backend)
+ - Ensures agent-client and service-client relationships
+ """
+ be = self.cache_backend
+ for tool_name, tool_def in tools.items():
+ be.map_tool_to_service(agent_id, tool_name, service_name)
+ be.upsert_tool_def(agent_id, tool_name, tool_def)
+ # 使用新的 AgentClientMappingService
+ self.registry._agent_client_service.add_agent_client_mapping(agent_id, client_id)
+ # 使用新的 AgentClientMappingService
+ self.registry._agent_client_service.add_service_client_mapping(agent_id, service_name, client_id)
+
+ @atomic_write(agent_id_param="agent_id", use_lock=True)
+ async def clear_service_tools(self, agent_id: str, service_name: str, tool_names: Iterable[str]) -> None:
+ """Remove tool defs and tool→service mappings for given names.
+ Service→client mapping is not modified here.
+ """
+ be = self.cache_backend
+ for tool_name in tool_names:
+ be.delete_tool_def(agent_id, tool_name)
+ # remove tool→service mapping if backend supports it (optional semantics)
+ # For simplicity we can re-map whole hash by deleting specific field when available
+ try:
+ be.unmap_tool_from_service(agent_id, tool_name) # type: ignore[attr-defined]
+ except Exception:
+ # Optional method; ignore if not provided by backend
+ pass
+
+ # ---------------------------- Small granular ops ---------------------------
+
+ @atomic_write(agent_id_param="agent_id", use_lock=True)
+ async def map_service_client(self, agent_id: str, service_name: str, client_id: str) -> None:
+ # 使用新的 AgentClientMappingService
+ self.registry._agent_client_service.add_service_client_mapping(agent_id, service_name, client_id)
+
+ @atomic_write(agent_id_param="agent_id", use_lock=True)
+ async def add_agent_client(self, agent_id: str, client_id: str) -> None:
+ # 使用新的 AgentClientMappingService
+ self.registry._agent_client_service.add_agent_client_mapping(agent_id, client_id)
+
+ @atomic_write(agent_id_param="agent_id", use_lock=True)
+ async def upsert_tool(self, agent_id: str, service_name: str, tool_name: str, tool_def: Dict[str, Any]) -> None:
+ self.cache_backend.map_tool_to_service(agent_id, tool_name, service_name)
+ self.cache_backend.upsert_tool_def(agent_id, tool_name, tool_def)
+
+ # ------------------------------ Read-throughs ------------------------------
+
+ def list_tool_names(self, agent_id: str):
+ return self.cache_backend.list_tool_names(agent_id)
+
+ def get_tool_def(self, agent_id: str, tool_name: str):
+ return self.cache_backend.get_tool_def(agent_id, tool_name)
+
+ def get_service_client_id(self, agent_id: str, service_name: str) -> Optional[str]:
+ return self.cache_backend.get_service_client_id(agent_id, service_name)
+
+ async def get_agent_clients_async(self, agent_id: str):
+ """从 pykv 获取 Agent 的所有 Client ID(异步版本)"""
+ return await self.registry.get_agent_clients_async(agent_id)
+
+ def get_agent_clients(self, agent_id: str):
+ """从 pykv 获取 Agent 的所有 Client ID(同步版本)"""
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ raise RuntimeError("get_agent_clients cannot be called in async context, please use get_agent_clients_async")
+ except RuntimeError:
+ return asyncio.run(self.get_agent_clients_async(agent_id))
+
+ def get_client_config(self, client_id: str):
+ return self.cache_backend.get_client_config_from_cache(client_id)
+
diff --git a/src/mcpstore/core/registry/schema_manager.py b/src/mcpstore/core/registry/schema_manager.py
new file mode 100644
index 00000000..8bea1d43
--- /dev/null
+++ b/src/mcpstore/core/registry/schema_manager.py
@@ -0,0 +1,49 @@
+"""
+Schema Manager - Placeholder for missing dependency
+"""
+#TODO:这个是做什么的?
+def get_schema_manager():
+ """
+ Get schema manager instance.
+ Placeholder implementation until proper schema management is implemented.
+ """
+ class MockSchemaManager:
+ def __init__(self):
+ pass
+
+ def validate_schema(self, schema):
+ return True
+
+ def get_schema_version(self):
+ return "1.0.0"
+
+ def get_known_service_config(self, service_name: str) -> dict:
+ """
+ Get known service configuration placeholder
+
+ Args:
+ service_name: Name of the service
+
+ Returns:
+ dict: Service configuration
+ """
+ # Basic placeholder configurations for known services
+ known_configs = {
+ "mcpstore-wiki": {
+ "name": "mcpstore-wiki",
+ "url": "https://www.mcpstore.wiki/mcp",
+ "description": "MCPStore Wiki documentation service"
+ },
+ "howtocook": {
+ "name": "howtocook",
+ "url": "https://api.example.com/cooking",
+ "description": "Cooking recipe service"
+ }
+ }
+ return known_configs.get(service_name, {
+ "name": service_name,
+ "url": f"https://api.example.com/{service_name}",
+ "description": f"{service_name} service"
+ })
+
+ return MockSchemaManager()
\ No newline at end of file
diff --git a/src/mcpstore/core/registry/scope_resolver.py b/src/mcpstore/core/registry/scope_resolver.py
new file mode 100644
index 00000000..1b2aa26d
--- /dev/null
+++ b/src/mcpstore/core/registry/scope_resolver.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Tuple, Optional
+
+
+#TODO:这个是干什么的?
+@dataclass
+class ScopeContext:
+ device_id: Optional[str] = None
+ user_id: Optional[str] = None
+
+
+class ScopeResolver:
+ """Computes read precedence across scopes: device > user > pub.
+
+ - For read operations, return the ordered list of (scope, owner) to check.
+ - For write operations, callers decide the target scope; usually device or user.
+ """
+
+ PUB_OWNER = "_pub"
+
+ def get_read_order(self, ctx: ScopeContext) -> List[Tuple[str, str]]:
+ order: List[Tuple[str, str]] = []
+ if ctx.device_id:
+ order.append(("device", ctx.device_id))
+ if ctx.user_id:
+ order.append(("user", ctx.user_id))
+ order.append(("pub", self.PUB_OWNER))
+ return order
+
diff --git a/src/mcpstore/core/registry/smart_query.py b/src/mcpstore/core/registry/smart_query.py
new file mode 100644
index 00000000..1a1a18c3
--- /dev/null
+++ b/src/mcpstore/core/registry/smart_query.py
@@ -0,0 +1,291 @@
+import logging
+from datetime import datetime
+from typing import Dict, Any, List, Optional
+
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+
+class SmartCacheQuery:
+ """Smart cache query interface"""
+
+ def __init__(self, registry):
+ self.registry = registry
+
+ def services(self, agent_id: str) -> 'ServiceQueryBuilder':
+ """Create service query builder"""
+ return ServiceQueryBuilder(self.registry, agent_id)
+
+ def agents(self) -> 'AgentQueryBuilder':
+ """Create Agent query builder"""
+ return AgentQueryBuilder(self.registry)
+
+ def clients(self, agent_id: str) -> 'ClientQueryBuilder':
+ """Create Client query builder"""
+ return ClientQueryBuilder(self.registry, agent_id)
+
+
+class ServiceQueryBuilder:
+ """Service query builder"""
+
+ def __init__(self, registry, agent_id: str):
+ self.registry = registry
+ self.agent_id = agent_id
+ self._filters = []
+ self._sorts = []
+ self._limit = None
+
+ def healthy(self):
+ """只查询健康的服务"""
+ self._filters.append(('state', [ServiceConnectionState.HEALTHY, ServiceConnectionState.WARNING]))
+ return self
+
+ def failed(self):
+ """只查询失败的服务"""
+ self._filters.append(('state', [ServiceConnectionState.UNREACHABLE, ServiceConnectionState.DISCONNECTED]))
+ return self
+
+ def with_tools(self, min_count: int = 1):
+ """查询有工具的服务"""
+ self._filters.append(('tool_count', '>=', min_count))
+ return self
+
+ def name_like(self, pattern: str):
+ """按名称模式查询"""
+ self._filters.append(('name_pattern', pattern))
+ return self
+
+ def transport_type(self, transport: str):
+ """按传输类型查询"""
+ self._filters.append(('transport', transport))
+ return self
+
+ def sort_by_name(self, desc: bool = False):
+ """按名称排序"""
+ self._sorts.append(('name', desc))
+ return self
+
+ def sort_by_tool_count(self, desc: bool = True):
+ """按工具数量排序"""
+ self._sorts.append(('tool_count', desc))
+ return self
+
+ def sort_by_last_heartbeat(self, desc: bool = True):
+ """按最后心跳时间排序"""
+ self._sorts.append(('last_heartbeat', desc))
+ return self
+
+ def limit(self, count: int):
+ """限制结果数量"""
+ self._limit = count
+ return self
+
+ def execute(self) -> List[Dict[str, Any]]:
+ """执行查询"""
+ # 获取所有服务
+ all_services = self.registry.get_all_services_complete_info(self.agent_id)
+
+ # 应用过滤器
+ filtered_services = []
+ for service in all_services:
+ if self._matches_filters(service):
+ filtered_services.append(service)
+
+ # 应用排序
+ for sort_field, desc in reversed(self._sorts):
+ filtered_services.sort(
+ key=lambda s: self._get_sort_value(s, sort_field),
+ reverse=desc
+ )
+
+ # 应用限制
+ if self._limit:
+ filtered_services = filtered_services[:self._limit]
+
+ return filtered_services
+
+ def count(self) -> int:
+ """获取匹配的服务数量"""
+ return len(self.execute())
+
+ def first(self) -> Optional[Dict[str, Any]]:
+ """获取第一个匹配的服务"""
+ results = self.limit(1).execute()
+ return results[0] if results else None
+
+ def _matches_filters(self, service: Dict[str, Any]) -> bool:
+ """检查服务是否匹配过滤条件"""
+ for filter_type, *filter_args in self._filters:
+ if filter_type == 'state':
+ allowed_states = filter_args[0]
+ service_state_str = service.get('state', 'unknown')
+ # 将字符串状态转换为枚举进行比较
+ try:
+ service_state = ServiceConnectionState(service_state_str)
+ if service_state not in allowed_states:
+ return False
+ except ValueError:
+ return False
+ elif filter_type == 'tool_count':
+ operator, threshold = filter_args
+ tool_count = service.get('tool_count', 0)
+ if operator == '>=' and tool_count < threshold:
+ return False
+ elif operator == '>' and tool_count <= threshold:
+ return False
+ elif operator == '<=' and tool_count > threshold:
+ return False
+ elif operator == '<' and tool_count >= threshold:
+ return False
+ elif operator == '==' and tool_count != threshold:
+ return False
+ elif filter_type == 'name_pattern':
+ pattern = filter_args[0]
+ if pattern.lower() not in service.get('name', '').lower():
+ return False
+ elif filter_type == 'transport':
+ transport = filter_args[0]
+ service_transport = service.get('config', {}).get('transport', 'unknown')
+ if transport.lower() != service_transport.lower():
+ return False
+
+ return True
+
+ def _get_sort_value(self, service: Dict[str, Any], field: str):
+ """获取排序字段的值"""
+ if field == 'name':
+ return service.get('name', '')
+ elif field == 'tool_count':
+ return service.get('tool_count', 0)
+ elif field == 'last_heartbeat':
+ heartbeat = service.get('last_heartbeat')
+ if heartbeat:
+ if isinstance(heartbeat, str):
+ try:
+ return datetime.fromisoformat(heartbeat.replace('Z', '+00:00'))
+ except ValueError:
+ return datetime.min
+ elif isinstance(heartbeat, datetime):
+ return heartbeat
+ return datetime.min
+ return ''
+
+
+class AgentQueryBuilder:
+ """Agent查询构建器"""
+
+ def __init__(self, registry):
+ self.registry = registry
+
+ async def with_services_async(self, min_count: int = 1):
+ """查询有服务的Agent(异步版本)"""
+ agents_with_services = []
+ # Use get_all_agent_ids() instead of agent_clients.keys()
+ for agent_id in self.registry.get_all_agent_ids():
+ service_count = len(self.registry.get_all_service_names(agent_id))
+ if service_count >= min_count:
+ # 从 pykv 获取 client_ids
+ client_ids = await self.registry.get_agent_clients_async(agent_id)
+ agents_with_services.append({
+ 'agent_id': agent_id,
+ 'service_count': service_count,
+ 'client_count': len(client_ids)
+ })
+ return agents_with_services
+
+ def with_services(self, min_count: int = 1):
+ """查询有服务的Agent(同步版本 - 使用 asyncio.run)"""
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ raise RuntimeError("with_services cannot be called in async context, please use with_services_async")
+ except RuntimeError:
+ return asyncio.run(self.with_services_async(min_count))
+
+ async def get_all_async(self) -> List[Dict[str, Any]]:
+ """获取所有Agent信息(异步版本)"""
+ agents = []
+ # Use get_all_agent_ids() instead of agent_clients.keys()
+ for agent_id in self.registry.get_all_agent_ids():
+ # 从 pykv 获取 client_ids
+ client_ids = await self.registry.get_agent_clients_async(agent_id)
+ agents.append({
+ 'agent_id': agent_id,
+ 'service_count': len(self.registry.get_all_service_names(agent_id)),
+ 'client_count': len(client_ids),
+ 'healthy_services': len(self.registry.get_healthy_services(agent_id)),
+ 'failed_services': len(self.registry.get_failed_services(agent_id))
+ })
+ return agents
+
+ def get_all(self) -> List[Dict[str, Any]]:
+ """获取所有Agent信息(同步版本 - 使用 asyncio.run)"""
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ raise RuntimeError("get_all cannot be called in async context, please use get_all_async")
+ except RuntimeError:
+ return asyncio.run(self.get_all_async())
+
+
+class ClientQueryBuilder:
+ """Client查询构建器"""
+
+ def __init__(self, registry, agent_id: str):
+ self.registry = registry
+ self.agent_id = agent_id
+
+ async def with_services_async(self, min_count: int = 1):
+ """查询有服务的Client(异步版本)"""
+ clients_with_services = []
+ # 从 pykv 获取 client_ids
+ client_ids = await self.registry.get_agent_clients_async(self.agent_id)
+
+ for client_id in client_ids:
+ client_config = self.registry.get_client_config_from_cache(client_id)
+ if client_config:
+ service_count = len(client_config.get('mcpServers', {}))
+ if service_count >= min_count:
+ clients_with_services.append({
+ 'client_id': client_id,
+ 'service_count': service_count,
+ 'services': list(client_config.get('mcpServers', {}).keys())
+ })
+
+ return clients_with_services
+
+ def with_services(self, min_count: int = 1):
+ """查询有服务的Client(同步版本 - 使用 asyncio.run)"""
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ raise RuntimeError("with_services cannot be called in async context, please use with_services_async")
+ except RuntimeError:
+ return asyncio.run(self.with_services_async(min_count))
+
+ async def get_all_async(self) -> List[Dict[str, Any]]:
+ """获取Agent下所有Client信息(异步版本)"""
+ clients = []
+ # 从 pykv 获取 client_ids
+ client_ids = await self.registry.get_agent_clients_async(self.agent_id)
+
+ for client_id in client_ids:
+ client_config = self.registry.get_client_config_from_cache(client_id)
+ clients.append({
+ 'client_id': client_id,
+ 'service_count': len(client_config.get('mcpServers', {})) if client_config else 0,
+ 'services': list(client_config.get('mcpServers', {}).keys()) if client_config else [],
+ 'config': client_config
+ })
+
+ return clients
+
+ def get_all(self) -> List[Dict[str, Any]]:
+ """获取Agent下所有Client信息(同步版本 - 使用 asyncio.run)"""
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ raise RuntimeError("get_all cannot be called in async context, please use get_all_async")
+ except RuntimeError:
+ return asyncio.run(self.get_all_async())
diff --git a/src/mcpstore/core/registry/state_backend.py b/src/mcpstore/core/registry/state_backend.py
new file mode 100644
index 00000000..e0716223
--- /dev/null
+++ b/src/mcpstore/core/registry/state_backend.py
@@ -0,0 +1,29 @@
+"""
+Deprecated state backend (stub).
+
+This file is intentionally minimal to keep legacy imports from breaking while
+explicitly directing callers to the new cache-layer architecture
+(`mcpstore.core.cache.*`). Any attempt to instantiate or use the classes here
+will raise a RuntimeError.
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class RegistryStateBackend:
+ """Legacy interface stub."""
+
+ def __init__(self, *args, **kwargs) -> None: # pragma: no cover - legacy stub
+ raise RuntimeError(
+ "RegistryStateBackend is deprecated and no longer supported. "
+ "Use CacheLayerManager with cache/state_manager.py instead."
+ )
+
+
+class KVRegistryStateBackend(RegistryStateBackend):
+ """Legacy KV-backed implementation stub."""
+
+ def __init__(self, *args, **kwargs) -> None: # pragma: no cover - legacy stub
+ super().__init__(*args, **kwargs)
diff --git a/src/mcpstore/core/registry/tool_resolver.py b/src/mcpstore/core/registry/tool_resolver.py
new file mode 100644
index 00000000..903cd081
--- /dev/null
+++ b/src/mcpstore/core/registry/tool_resolver.py
@@ -0,0 +1,683 @@
+#!/usr/bin/env python3
+"""
+Unified Tool Name Resolver - Based on FastMCP Official Standards
+Provides user-friendly tool name input, internally converts to FastMCP standard format
+"""
+
+import logging
+import re
+from dataclasses import dataclass
+from typing import Optional, List, Dict, Any
+
+from ..models.tool_result import CallToolFailureResult
+
+logger = logging.getLogger(__name__)
+
+@dataclass
+class ToolResolution:
+ """Tool resolution result"""
+ service_name: str # Service name
+ original_tool_name: str # FastMCP standard original tool name
+ user_input: str # User input tool name
+ resolution_method: str # Resolution method (exact_match, prefix_match, fuzzy_match)
+
+class ToolNameResolver:
+ """
+ Intelligent user-friendly tool name resolver - FastMCP 2.0 standard
+
+ [FEATURES] Core features:
+ 1. Extremely loose user input: supports any reasonable format
+ 2. Strict FastMCP standard: fully compliant with official specifications internally
+ 3. Intelligent unambiguous recognition: automatically handles single/multi-service scenarios
+ 4. Perfect backward compatibility: maintains existing functionality unchanged
+
+ [SUPPORTED] Input formats:
+ - Original tool name: get_current_weather
+ - With prefix: mcpstore-demo-weather_get_current_weather
+ - Partial match: current_weather, weather
+ - Fuzzy match: getcurrentweather, get-current-weather
+ """
+
+ def __init__(self, available_services: List[str] = None, is_multi_server: bool = None):
+ """
+ Initialize intelligent resolver
+
+ Args:
+ available_services: List of available services
+ is_multi_server: Whether it's a multi-service scenario (None=auto-detect)
+ """
+ self.available_services = available_services or []
+ self.is_multi_server = is_multi_server if is_multi_server is not None else len(self.available_services) > 1
+ self._service_tools_cache: Dict[str, List[str]] = {}
+
+ # Preprocess service name mapping
+ self._service_name_mapping = {}
+ for service in self.available_services:
+ normalized = self._normalize_service_name(service)
+ self._service_name_mapping[normalized] = service
+ self._service_name_mapping[service] = service
+
+ # logger.debug(f"[RESOLVER] init services={len(self.available_services)} multi_server={self.is_multi_server}")
+
+ def resolve_tool_name_smart(self, user_input: str, available_tools: List[Dict[str, Any]] = None) -> ToolResolution:
+ """
+ [SMART] Intelligent user-friendly tool name resolution (new version)
+
+ Supports extremely loose user input, automatically converts to FastMCP standard format:
+
+ Input examples:
+ - "get_current_weather" → Auto-detect service and add prefix (multi-service)
+ - "mcpstore-demo-weather_get_current_weather" → Parse and validate
+ - "weather" → Intelligently match most similar tool
+ - "getcurrentweather" → Fuzzy match and suggest
+
+ Args:
+ user_input: User input tool name (any format)
+ available_tools: List of available tools
+
+ Returns:
+ ToolResolution: Resolution result containing FastMCP standard format
+ """
+ if not user_input or not isinstance(user_input, str):
+ raise ValueError("Tool name cannot be empty")
+
+ user_input = user_input.strip()
+ logger.debug(f"[SMART_RESOLVE] start input='{user_input}' multi_server={self.is_multi_server}")
+
+ # Build tool mapping table
+ tool_mappings = self._build_smart_tool_mappings(available_tools or [])
+
+ # Smart resolution process
+ resolution = None
+
+ # 1. Exact match (highest priority)
+ resolution = self._try_exact_match(user_input, tool_mappings)
+ if resolution:
+ logger.debug(f"[EXACT_MATCH] {user_input} -> {resolution.service_name}::{resolution.original_tool_name}")
+ return resolution
+
+ # 2. Smart prefix match
+ resolution = self._try_prefix_match(user_input, tool_mappings)
+ if resolution:
+ logger.debug(f"[PREFIX_MATCH] {user_input} -> {resolution.service_name}::{resolution.original_tool_name}")
+ return resolution
+
+ # 3. No prefix smart match (single service optimization)
+ resolution = self._try_no_prefix_match(user_input, tool_mappings)
+ if resolution:
+ logger.debug(f"[NO_PREFIX_MATCH] {user_input} -> {resolution.service_name}::{resolution.original_tool_name}")
+ return resolution
+
+ # 4. Smart fuzzy match
+ resolution = self._try_fuzzy_match(user_input, tool_mappings)
+ if resolution:
+ logger.debug(f"[FUZZY_MATCH] {user_input} -> {resolution.service_name}::{resolution.original_tool_name}")
+ return resolution
+
+ # 5. Failure handling: provide smart suggestions
+ suggestions = self._get_smart_suggestions(user_input, tool_mappings)
+ if suggestions:
+ raise ValueError(f"Tool '{user_input}' not found. Did you mean: {', '.join(suggestions[:3])}?")
+ else:
+ raise ValueError(f"Tool '{user_input}' not found and no similar suggestions available")
+
+ def resolve_tool_name(self, user_input: str, available_tools: List[Dict[str, Any]] = None) -> ToolResolution:
+ """
+ Resolve user-input tool name
+
+ Args:
+ user_input: User-input tool name
+ available_tools: Available tools list [{"name": "display_name", "original_name": "tool", "service_name": "service"}]
+
+ Returns:
+ ToolResolution: Resolution result
+
+ Raises:
+ ValueError: Cannot resolve tool name
+ """
+ if not user_input or not isinstance(user_input, str):
+ raise ValueError("Tool name cannot be empty")
+
+ user_input = user_input.strip()
+ available_tools = available_tools or []
+
+ # Build tool mapping (support display names and original names)
+ display_to_original = {} # display_name -> (original_name, service_name)
+ original_to_service = {} # original_name -> service_name
+ service_tools = {} # service_name -> [original_tool_name_list]
+
+ for tool in available_tools:
+ display_name = tool.get("name", "") # display name
+ original_name = tool.get("original_name") or tool.get("name", "") # original name
+ service_name = tool.get("service_name", "")
+
+ display_to_original[display_name] = (original_name, service_name)
+ original_to_service[original_name] = service_name
+
+ if service_name not in service_tools:
+ service_tools[service_name] = []
+ if original_name not in service_tools[service_name]:
+ service_tools[service_name].append(original_name)
+
+ logger.debug(f"Resolving tool: {user_input}")
+ logger.debug(f"Available services: {list(service_tools.keys())}")
+
+ # 1. Exact match: display name
+ if user_input in display_to_original:
+ original_name, service_name = display_to_original[user_input]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="exact_display_match"
+ )
+
+ # 2. Exact match: original name
+ if user_input in original_to_service:
+ return ToolResolution(
+ service_name=original_to_service[user_input],
+ original_tool_name=user_input,
+ user_input=user_input,
+ resolution_method="exact_original_match"
+ )
+
+ # 3. Single underscore format parsing: service_tool (exact service name match)
+ if "_" in user_input and "__" not in user_input:
+ # Try all possible split points
+ for i in range(1, len(user_input)):
+ if user_input[i] == "_":
+ potential_service = user_input[:i]
+ potential_tool = user_input[i+1:]
+
+ # Check if there's a matching service (support original names and normalized names)
+ matched_service = None
+ if potential_service in service_tools:
+ matched_service = potential_service
+ elif potential_service in self._service_name_mapping:
+ matched_service = self._service_name_mapping[potential_service]
+
+ if matched_service and potential_tool in service_tools[matched_service]:
+ logger.debug(f"Single underscore match: {potential_service} -> {matched_service}, tool: {potential_tool}")
+ return ToolResolution(
+ service_name=matched_service,
+ original_tool_name=potential_tool,
+ user_input=user_input,
+ resolution_method="single_underscore_match"
+ )
+
+ # 4. Check if deprecated double underscore format is used
+ if "__" in user_input:
+ parts = user_input.split("__", 1)
+ if len(parts) == 2:
+ potential_service, potential_tool = parts
+ single_underscore_format = f"{potential_service}_{potential_tool}"
+ raise ValueError(
+ f"Double underscore format '__' is no longer supported. "
+ f"Please use single underscore format: '{single_underscore_format}'"
+ )
+
+ # 5. Fuzzy match: find similar names in all tools
+ fuzzy_matches = []
+ for display_name, (original_name, service_name) in display_to_original.items():
+ if self._is_fuzzy_match(user_input, display_name) or self._is_fuzzy_match(user_input, original_name):
+ fuzzy_matches.append((original_name, service_name, display_name))
+
+ if len(fuzzy_matches) == 1:
+ original_name, service_name, display_name = fuzzy_matches[0]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="fuzzy_match"
+ )
+ elif len(fuzzy_matches) > 1:
+ # Multiple matches, provide suggestions
+ suggestions = [display_name for _, _, display_name in fuzzy_matches[:3]]
+ raise ValueError(f"Ambiguous tool name '{user_input}'. Did you mean: {', '.join(suggestions)}?")
+
+ # 6. Cannot resolve, provide suggestions
+ if available_tools:
+ all_display_names = list(display_to_original.keys())
+ suggestions = self._get_suggestions(user_input, all_display_names)
+ if suggestions:
+ raise ValueError(f"Tool '{user_input}' not found. Did you mean: {', '.join(suggestions[:3])}?")
+
+ raise ValueError(f"Tool '{user_input}' not found")
+
+ def create_user_friendly_name(self, service_name: str, tool_name: str) -> str:
+ """
+ Create user-friendly tool name (for display)
+
+ Uses single underscore format, keeping service name in original form
+
+ Args:
+ service_name: Service name (keep original format)
+ tool_name: Original tool name
+
+ Returns:
+ User-friendly tool name
+ """
+ # Use single underscore, keep service name in original format
+ return f"{service_name}_{tool_name}"
+
+ def _normalize_service_name(self, service_name: str) -> str:
+ """Normalize service name"""
+ # Remove special characters, convert to underscores
+ normalized = re.sub(r'[^a-zA-Z0-9_]', '_', service_name)
+ # Remove consecutive underscores
+ normalized = re.sub(r'_+', '_', normalized)
+ # Remove leading and trailing underscores
+ normalized = normalized.strip('_')
+ return normalized or "unnamed"
+
+ def _is_fuzzy_match(self, user_input: str, tool_name: str) -> bool:
+ """Check if it's a fuzzy match"""
+ user_lower = user_input.lower()
+ tool_lower = tool_name.lower()
+
+ # Complete containment
+ if user_lower in tool_lower or tool_lower in user_lower:
+ return True
+
+ # Match after removing underscores
+ user_clean = user_lower.replace('_', '').replace('-', '')
+ tool_clean = tool_lower.replace('_', '').replace('-', '')
+
+ if user_clean in tool_clean or tool_clean in user_clean:
+ return True
+
+ return False
+
+ def _get_suggestions(self, user_input: str, available_names: List[str]) -> List[str]:
+ """Get suggested tool names"""
+ suggestions = []
+ user_lower = user_input.lower()
+
+ for name in available_names:
+ name_lower = name.lower()
+ # Prefix match
+ if name_lower.startswith(user_lower) or user_lower.startswith(name_lower):
+ suggestions.append(name)
+ # Containment match
+ elif user_lower in name_lower or name_lower in user_lower:
+ suggestions.append(name)
+
+ return sorted(suggestions, key=lambda x: len(x))[:5]
+
+ def _build_smart_tool_mappings(self, available_tools: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """
+ Build smart tool mapping table
+
+ Returns:
+ Dictionary containing multiple mapping relationships:
+ - exact_matches: Exact match mapping
+ - prefix_matches: Prefix match mapping
+ - no_prefix_matches: No prefix match mapping
+ - fuzzy_candidates: Fuzzy match candidates
+ """
+ mappings = {
+ "exact_matches": {}, # {user_input: (service, original_tool)}
+ "prefix_matches": {}, # {prefix_removed: [(service, original_tool, full_name)]}
+ "no_prefix_matches": {}, # {tool_name: [(service, original_tool, full_name)]}
+ "fuzzy_candidates": [], # [(service, original_tool, full_name, display_name)]
+ "all_tools": [] # Complete information for all tools
+ }
+
+ for tool in available_tools:
+ service_name = tool.get("service_name", "")
+ original_name = tool.get("original_name", "")
+ display_name = tool.get("name", "")
+
+ if not service_name or not original_name:
+ continue
+
+ # Record all tools
+ tool_info = (service_name, original_name, display_name)
+ mappings["all_tools"].append(tool_info)
+ mappings["fuzzy_candidates"].append(tool_info + (display_name,))
+
+ # Exact matches: display names and original names
+ mappings["exact_matches"][display_name] = (service_name, original_name)
+ mappings["exact_matches"][original_name] = (service_name, original_name)
+
+ # Prefix matches: tool name after removing service name prefix
+ if display_name.startswith(f"{service_name}_"):
+ tool_suffix = display_name[len(service_name) + 1:]
+ if tool_suffix not in mappings["prefix_matches"]:
+ mappings["prefix_matches"][tool_suffix] = []
+ mappings["prefix_matches"][tool_suffix].append((service_name, original_name, display_name))
+
+ # No prefix matches: pure tool name
+ if original_name not in mappings["no_prefix_matches"]:
+ mappings["no_prefix_matches"][original_name] = []
+ mappings["no_prefix_matches"][original_name].append((service_name, original_name, display_name))
+
+ logger.debug(f"[MAPPINGS] built exact={len(mappings['exact_matches'])} prefix={len(mappings['prefix_matches'])} no_prefix={len(mappings['no_prefix_matches'])}")
+ return mappings
+
+ def _try_exact_match(self, user_input: str, mappings: Dict[str, Any]) -> Optional[ToolResolution]:
+ """Try exact match"""
+ if user_input in mappings["exact_matches"]:
+ service_name, original_name = mappings["exact_matches"][user_input]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="exact_match"
+ )
+ return None
+
+ def _try_prefix_match(self, user_input: str, mappings: Dict[str, Any]) -> Optional[ToolResolution]:
+ """Try prefix match: user input contains service name prefix"""
+ # Check if it contains service name prefix
+ for service_name in self.available_services:
+ if user_input.startswith(f"{service_name}_"):
+ tool_suffix = user_input[len(service_name) + 1:]
+ if tool_suffix in mappings["prefix_matches"]:
+ candidates = mappings["prefix_matches"][tool_suffix]
+ # Prioritize matching tools from the same service
+ for candidate_service, original_name, display_name in candidates:
+ if candidate_service == service_name:
+ return ToolResolution(
+ service_name=candidate_service,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="prefix_match"
+ )
+ return None
+
+ def _try_no_prefix_match(self, user_input: str, mappings: Dict[str, Any]) -> Optional[ToolResolution]:
+ """Try no prefix match: user input does not contain service name prefix"""
+ if user_input in mappings["no_prefix_matches"]:
+ candidates = mappings["no_prefix_matches"][user_input]
+
+ if len(candidates) == 1:
+ # Unique match
+ service_name, original_name, display_name = candidates[0]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="no_prefix_match"
+ )
+ elif len(candidates) > 1:
+ # Multiple matches, select first in single service mode, error in multi service mode
+ if not self.is_multi_server:
+ service_name, original_name, display_name = candidates[0]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="no_prefix_match_single_server"
+ )
+ else:
+ # Ambiguous in multi service mode, return None for subsequent processing
+ logger.debug(f"[NO_PREFIX] ambiguous user_input='{user_input}' candidates={len(candidates)}")
+ return None
+
+ def _try_fuzzy_match(self, user_input: str, mappings: Dict[str, Any]) -> Optional[ToolResolution]:
+ """Try fuzzy match: smart similarity matching"""
+ fuzzy_matches = []
+ user_clean = self._clean_for_fuzzy_match(user_input)
+
+ for service_name, original_name, display_name, _ in mappings["fuzzy_candidates"]:
+ # Check fuzzy matching of display names and original names
+ if self._is_smart_fuzzy_match(user_clean, display_name) or \
+ self._is_smart_fuzzy_match(user_clean, original_name):
+ fuzzy_matches.append((service_name, original_name, display_name))
+
+ if len(fuzzy_matches) == 1:
+ service_name, original_name, display_name = fuzzy_matches[0]
+ return ToolResolution(
+ service_name=service_name,
+ original_tool_name=original_name,
+ user_input=user_input,
+ resolution_method="fuzzy_match"
+ )
+ elif len(fuzzy_matches) > 1:
+ logger.debug(f"[FUZZY] multiple_matches input='{user_input}' count={len(fuzzy_matches)}")
+
+ return None
+
+ def _get_smart_suggestions(self, user_input: str, mappings: Dict[str, Any]) -> List[str]:
+ """Get smart suggestions"""
+ suggestions = []
+ user_lower = user_input.lower()
+ user_clean = self._clean_for_fuzzy_match(user_input)
+
+ # Collect all possible suggestions
+ candidates = []
+ for service_name, original_name, display_name, _ in mappings["fuzzy_candidates"]:
+ score = self._calculate_similarity_score(user_clean, display_name, original_name)
+ if score > 0:
+ candidates.append((score, display_name))
+
+ # Sort by similarity and return top few
+ candidates.sort(key=lambda x: x[0], reverse=True)
+ return [name for score, name in candidates[:5] if score > 0.3]
+
+ def _clean_for_fuzzy_match(self, text: str) -> str:
+ """Clean text for fuzzy matching"""
+ return re.sub(r'[^a-zA-Z0-9]', '', text.lower())
+
+ def _is_smart_fuzzy_match(self, user_clean: str, target: str) -> bool:
+ """Smart fuzzy match judgment"""
+ target_clean = self._clean_for_fuzzy_match(target)
+
+ # Complete containment
+ if user_clean in target_clean or target_clean in user_clean:
+ return True
+
+ # Prefix match (at least 3 characters)
+ if len(user_clean) >= 3 and (target_clean.startswith(user_clean) or user_clean.startswith(target_clean)):
+ return True
+
+ return False
+
+ def _calculate_similarity_score(self, user_clean: str, display_name: str, original_name: str) -> float:
+ """Calculate similarity score"""
+ display_clean = self._clean_for_fuzzy_match(display_name)
+ original_clean = self._clean_for_fuzzy_match(original_name)
+
+ max_score = 0.0
+
+ # Check display name
+ if user_clean == display_clean:
+ max_score = max(max_score, 1.0)
+ elif user_clean in display_clean:
+ max_score = max(max_score, 0.8)
+ elif display_clean.startswith(user_clean) or user_clean.startswith(display_clean):
+ max_score = max(max_score, 0.6)
+
+ # Check original name
+ if user_clean == original_clean:
+ max_score = max(max_score, 1.0)
+ elif user_clean in original_clean:
+ max_score = max(max_score, 0.8)
+ elif original_clean.startswith(user_clean) or user_clean.startswith(original_clean):
+ max_score = max(max_score, 0.6)
+
+ return max_score
+
+ def to_fastmcp_format(self, resolution: ToolResolution, available_tools: List[Dict[str, Any]] = None) -> str:
+ """
+ Convert to FastMCP standard format tool name
+
+ Important discovery:
+ - MCPStore internal: tool names with prefix "mcpstore-demo-weather_get_current_weather"
+ - FastMCP native: tool names without prefix "get_current_weather"
+ - We need to return the format expected by FastMCP native!
+
+ Args:
+ resolution: Tool resolution result
+ available_tools: Available tools list (for finding original names)
+
+ Returns:
+ Tool name expected by FastMCP native (original name without prefix)
+ """
+ # Key correction: FastMCP execution needs original tool name, not MCPStore internal prefixed name
+ logger.debug(f"[FASTMCP] native_tool_name={resolution.original_tool_name}")
+ return resolution.original_tool_name
+
+ def resolve_and_format_for_fastmcp(self, user_input: str, available_tools: List[Dict[str, Any]] = None) -> tuple[str, ToolResolution]:
+ """
+ One-stop resolution: user input → FastMCP standard format
+
+ This is the main external interface, completing the full conversion from user-friendly input to FastMCP standard format
+
+ Args:
+ user_input: User-input tool name (any format)
+ available_tools: Available tools list
+
+ Returns:
+ tuple: (fastmcp_format_name, resolution_details)
+ """
+ # 1. Smart resolution of user input
+ resolution = self.resolve_tool_name_smart(user_input, available_tools)
+
+ # 2. Convert to FastMCP standard format (pass available_tools for finding actual names)
+ fastmcp_name = self.to_fastmcp_format(resolution, available_tools)
+
+ logger.info(f"[RESOLVE_SUCCESS] input='{user_input}' fastmcp='{fastmcp_name}' service='{resolution.service_name}' method='{resolution.resolution_method}'")
+
+ return fastmcp_name, resolution
+
+class FastMCPToolExecutor:
+ """
+ FastMCP standard tool executor
+ Strictly executes tool calls according to official website standards
+ """
+
+ def __init__(self, default_timeout: float = 30.0):
+ """
+ Initialize executor
+
+ Args:
+ default_timeout: Default timeout time (seconds)
+ """
+ self.default_timeout = default_timeout
+
+ async def execute_tool(
+ self,
+ client,
+ tool_name: str,
+ arguments: Dict[str, Any] = None,
+ timeout: Optional[float] = None,
+ progress_handler = None,
+ raise_on_error: bool = True
+ ) -> 'CallToolResult':
+ """
+ Execute tool (strictly according to FastMCP official website standards)
+
+ Only use FastMCP official client's call_tool return object, without any custom "equivalent object" wrapping,
+ no longer fallback to call_tool_mcp for field mapping, ensuring result format matches official standards.
+
+ Args:
+ client: FastMCP client instance (must implement call_tool)
+ tool_name: Tool name (FastMCP original name)
+ arguments: Tool parameters
+ timeout: Timeout time (seconds)
+ progress_handler: Progress handler
+ raise_on_error: Whether to raise exception on error
+
+ Returns:
+ CallToolResult: FastMCP standard result object
+ """
+ arguments = arguments or {}
+ timeout = timeout or self.default_timeout
+
+ try:
+ if not hasattr(client, 'call_tool'):
+ raise RuntimeError("FastMCP client does not support call_tool; please use a compatible FastMCP client")
+
+ logger.debug("Using client.call_tool (FastMCP official) for result")
+ result = await client.call_tool(
+ name=tool_name,
+ arguments=arguments,
+ timeout=timeout,
+ progress_handler=progress_handler,
+ raise_on_error=raise_on_error,
+ )
+ return result
+
+ except Exception as e:
+ logger.error(f"Tool '{tool_name}' execution failed: {e}")
+ if raise_on_error:
+ raise
+ failure = CallToolFailureResult(str(e))
+ return failure.unwrap()
+
+ def extract_result_data(self, result: 'CallToolResult') -> Any:
+ """
+ Extract result data (strictly according to FastMCP official website standards)
+
+ Priority order according to official documentation:
+ 1. .data - FastMCP unique fully hydrated Python object
+ 2. .structured_content - Standard MCP structured JSON data
+ 3. .content - Standard MCP content blocks
+
+ Args:
+ result: FastMCP call result
+
+ Returns:
+ Extracted data
+ """
+ import logging
+ logger = logging.getLogger(__name__)
+
+ # Check error status
+ if hasattr(result, 'is_error') and result.is_error:
+ logger.warning(f"Tool execution failed, extracting error content")
+ # Even for errors, try to extract content
+
+ # 1. Prioritize .data property (FastMCP unique feature)
+ if hasattr(result, 'data') and result.data is not None:
+ logger.debug(f"Using FastMCP .data property: {type(result.data)}")
+ return result.data
+
+ # 2. Fallback to .structured_content (standard MCP structured data)
+ if hasattr(result, 'structured_content') and result.structured_content is not None:
+ logger.debug(f"Using MCP .structured_content: {result.structured_content}")
+ return result.structured_content
+
+ # 3. Finally use .content (standard MCP content blocks)
+ if hasattr(result, 'content') and result.content:
+ logger.debug(f"Using MCP .content blocks: {len(result.content)} items")
+
+ # According to official documentation, content is a ContentBlock list
+ if isinstance(result.content, list) and result.content:
+ # Extract data from all content blocks
+ extracted_content = []
+
+ for content_block in result.content:
+ if hasattr(content_block, 'text'):
+ logger.debug(f"Extracting text from TextContent: {content_block.text}")
+ extracted_content.append(content_block.text)
+ elif hasattr(content_block, 'data'):
+ logger.debug(f"Found binary content: {len(content_block.data)} bytes")
+ extracted_content.append(content_block.data)
+ else:
+ # For other types of content blocks, keep original object
+ logger.debug(f"Found other content block type: {type(content_block)}")
+ extracted_content.append(content_block)
+
+ # Decide return format based on extracted content count
+ if len(extracted_content) == 0:
+ # No extractable content, return first original content block
+ logger.debug(f"No extractable content found, returning first content block")
+ return result.content[0]
+ elif len(extracted_content) == 1:
+ # Only one content block, return content directly (maintain backward compatibility)
+ logger.debug(f"Single content block extracted, returning content directly")
+ return extracted_content[0]
+ else:
+ # Multiple content blocks, return list
+ logger.debug(f"Multiple content blocks extracted ({len(extracted_content)}), returning as list")
+ return extracted_content
+
+ # If content is not a list, return directly
+ return result.content
+
+ # 4. If no data from above, return None (matches official documentation fallback behavior)
+ logger.debug("No extractable data found in any standard properties, returning None")
+ return None
diff --git a/src/mcpstore/core/registry/types.py b/src/mcpstore/core/registry/types.py
new file mode 100644
index 00000000..a9ebbb5e
--- /dev/null
+++ b/src/mcpstore/core/registry/types.py
@@ -0,0 +1,78 @@
+"""
+Registry Types
+Type definitions related to the registry module
+
+Contains all type definitions used in the registry module for unified management and import.
+"""
+
+from datetime import datetime
+from typing import Dict, Any, TypeVar, Protocol
+
+# Re-export model types for unified import
+try:
+ from ..models.service import ServiceConnectionState, ServiceStateMetadata
+except ImportError:
+ # If model import fails, provide placeholders
+ ServiceConnectionState = None
+ ServiceStateMetadata = None
+
+# Define a protocol representing any session type with call_tool method
+class SessionProtocol(Protocol):
+ """Session protocol - defines interface that sessions must implement"""
+ async def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
+ """Call tool method"""
+ ...
+
+# Session type variable
+SessionType = TypeVar('SessionType')
+
+# Registration-related type aliases
+AgentId = str
+ServiceName = str
+ToolName = str
+ClientId = str
+
+# Registration data structure types
+SessionsDict = Dict[AgentId, Dict[ServiceName, Any]]
+ToolCacheDict = Dict[AgentId, Dict[ToolName, Any]]
+ToolToSessionDict = Dict[AgentId, Dict[ToolName, Any]]
+ServiceHealthDict = Dict[AgentId, Dict[ServiceName, datetime]]
+
+class RegistryTypes:
+ """Registry type collection - for unified management of all types"""
+
+ # Basic types
+ AgentId = AgentId
+ ServiceName = ServiceName
+ ToolName = ToolName
+ ClientId = ClientId
+
+ # Protocol types
+ SessionProtocol = SessionProtocol
+ SessionType = SessionType
+
+ # Data structure types
+ SessionsDict = SessionsDict
+ ToolCacheDict = ToolCacheDict
+ ToolToSessionDict = ToolToSessionDict
+ ServiceHealthDict = ServiceHealthDict
+
+ # Model types
+ ServiceConnectionState = ServiceConnectionState
+ ServiceStateMetadata = ServiceStateMetadata
+
+__all__ = [
+ 'SessionProtocol',
+ 'SessionType',
+ 'AgentId',
+ 'ServiceName',
+ 'ToolName',
+ 'ClientId',
+ 'SessionsDict',
+ 'ToolCacheDict',
+ 'ToolToSessionDict',
+ 'ServiceHealthDict',
+ 'RegistryTypes',
+ 'ServiceConnectionState',
+ 'ServiceStateMetadata'
+]
diff --git a/src/mcpstore/core/registry/wrapper_config.py b/src/mcpstore/core/registry/wrapper_config.py
new file mode 100644
index 00000000..3d853a9c
--- /dev/null
+++ b/src/mcpstore/core/registry/wrapper_config.py
@@ -0,0 +1,230 @@
+"""
+Configuration parser for py-key-value wrapper chain.
+
+This module provides utilities for parsing and validating wrapper configuration
+from user-provided config dictionaries.
+
+Validates:
+ - Requirements 17.1: 统计包装器配置
+ - Requirements 17.2: 大小限制包装器配置
+ - Requirements 17.3: 压缩包装器配置
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, Optional
+
+from ...config.config_defaults import WrapperConfigDefaults
+
+logger = logging.getLogger(__name__)
+
+_wrapper_defaults = WrapperConfigDefaults()
+
+
+class WrapperConfig:
+ """
+ Parsed and validated wrapper configuration.
+
+ This class encapsulates all wrapper-related configuration options,
+ providing defaults and validation.
+
+ Attributes:
+ enable_statistics: Whether to enable StatisticsWrapper
+ enable_size_limit: Whether to enable LimitSizeWrapper
+ max_item_size: Maximum item size in bytes (for LimitSizeWrapper)
+ enable_compression: Whether to enable CompressionWrapper
+ compression_threshold: Compression threshold in bytes
+
+ Validates:
+ - Requirements 17.1: 统计包装器配置
+ - Requirements 17.2: 大小限制包装器配置
+ - Requirements 17.3: 压缩包装器配置
+ """
+
+ # Default values
+ DEFAULT_ENABLE_STATISTICS = True
+ DEFAULT_ENABLE_SIZE_LIMIT = True
+ DEFAULT_MAX_ITEM_SIZE = _wrapper_defaults.DEFAULT_MAX_ITEM_SIZE # 1MB
+ DEFAULT_ENABLE_COMPRESSION = False
+ DEFAULT_COMPRESSION_THRESHOLD = _wrapper_defaults.DEFAULT_COMPRESSION_THRESHOLD # 由 WrapperConfigDefaults 统一管理
+
+ def __init__(
+ self,
+ enable_statistics: bool = DEFAULT_ENABLE_STATISTICS,
+ enable_size_limit: bool = DEFAULT_ENABLE_SIZE_LIMIT,
+ max_item_size: int = DEFAULT_MAX_ITEM_SIZE,
+ enable_compression: bool = DEFAULT_ENABLE_COMPRESSION,
+ compression_threshold: int = DEFAULT_COMPRESSION_THRESHOLD
+ ):
+ """
+ Initialize wrapper configuration.
+
+ Args:
+ enable_statistics: Enable statistics wrapper
+ enable_size_limit: Enable size limit wrapper
+ max_item_size: Maximum item size in bytes
+ enable_compression: Enable compression wrapper
+ compression_threshold: Compression threshold in bytes
+ """
+ self.enable_statistics = enable_statistics
+ self.enable_size_limit = enable_size_limit
+ self.max_item_size = max_item_size
+ self.enable_compression = enable_compression
+ self.compression_threshold = compression_threshold
+
+ # Validate configuration
+ self._validate()
+
+ def _validate(self) -> None:
+ """
+ Validate configuration values.
+
+ Raises:
+ ValueError: If configuration is invalid
+ """
+ # Validate max_item_size
+ if self.enable_size_limit:
+ if not isinstance(self.max_item_size, int) or self.max_item_size <= 0:
+ raise ValueError(
+ f"max_item_size must be a positive integer, got: {self.max_item_size}"
+ )
+
+ # Warn if size is too small
+ if self.max_item_size < 1024: # Less than 1KB
+ logger.warning(
+ f"max_item_size is very small ({self.max_item_size} bytes). "
+ f"This may cause issues with normal data."
+ )
+
+ # Validate compression_threshold
+ if self.enable_compression:
+ if not isinstance(self.compression_threshold, int) or self.compression_threshold <= 0:
+ raise ValueError(
+ f"compression_threshold must be a positive integer, got: {self.compression_threshold}"
+ )
+
+ # Warn if threshold is larger than max size
+ if self.enable_size_limit and self.compression_threshold > self.max_item_size:
+ logger.warning(
+ f"compression_threshold ({self.compression_threshold}) is larger than "
+ f"max_item_size ({self.max_item_size}). Compression may never trigger."
+ )
+
+ @classmethod
+ def from_dict(cls, config: Optional[Dict[str, Any]] = None) -> 'WrapperConfig':
+ """
+ Parse wrapper configuration from a dictionary.
+
+ Args:
+ config: Configuration dictionary with optional keys:
+ - enable_statistics: bool (default: True)
+ - enable_size_limit: bool (default: True)
+ - max_item_size: int (default: 1MB)
+ - enable_compression: bool (default: False)
+ - compression_threshold: int (default: 512KB)
+
+ Returns:
+ WrapperConfig instance with parsed values
+
+ Examples:
+ >>> # Use defaults
+ >>> config = WrapperConfig.from_dict()
+
+ >>> # Custom configuration
+ >>> config = WrapperConfig.from_dict({
+ ... "enable_statistics": True,
+ ... "enable_size_limit": True,
+ ... "max_item_size": 2 * 1024 * 1024, # 2MB
+ ... "enable_compression": True,
+ ... "compression_threshold": 1024 * 1024 # 1MB
+ ... })
+
+ Validates:
+ - Requirements 17.1: 解析 enable_statistics
+ - Requirements 17.2: 解析 enable_size_limit 和 max_item_size
+ - Requirements 17.3: 解析 enable_compression 和 compression_threshold
+ """
+ config = config or {}
+
+ # Parse each configuration option with defaults
+ enable_statistics = config.get("enable_statistics", cls.DEFAULT_ENABLE_STATISTICS)
+ enable_size_limit = config.get("enable_size_limit", cls.DEFAULT_ENABLE_SIZE_LIMIT)
+ max_item_size = config.get("max_item_size", cls.DEFAULT_MAX_ITEM_SIZE)
+ enable_compression = config.get("enable_compression", cls.DEFAULT_ENABLE_COMPRESSION)
+ compression_threshold = config.get("compression_threshold", cls.DEFAULT_COMPRESSION_THRESHOLD)
+
+ # Type coercion for robustness
+ try:
+ enable_statistics = bool(enable_statistics)
+ enable_size_limit = bool(enable_size_limit)
+ max_item_size = int(max_item_size)
+ enable_compression = bool(enable_compression)
+ compression_threshold = int(compression_threshold)
+ except (TypeError, ValueError) as e:
+ raise ValueError(f"Invalid wrapper configuration: {e}") from e
+
+ logger.debug(
+ f"Parsed wrapper config: statistics={enable_statistics}, "
+ f"size_limit={enable_size_limit} (max={max_item_size}), "
+ f"compression={enable_compression} (threshold={compression_threshold})"
+ )
+
+ return cls(
+ enable_statistics=enable_statistics,
+ enable_size_limit=enable_size_limit,
+ max_item_size=max_item_size,
+ enable_compression=enable_compression,
+ compression_threshold=compression_threshold
+ )
+
+ def to_dict(self) -> Dict[str, Any]:
+ """
+ Convert configuration to dictionary.
+
+ Returns:
+ Dictionary representation of configuration
+ """
+ return {
+ "enable_statistics": self.enable_statistics,
+ "enable_size_limit": self.enable_size_limit,
+ "max_item_size": self.max_item_size,
+ "enable_compression": self.enable_compression,
+ "compression_threshold": self.compression_threshold
+ }
+
+ def __repr__(self) -> str:
+ """String representation of configuration."""
+ return (
+ f"WrapperConfig("
+ f"statistics={self.enable_statistics}, "
+ f"size_limit={self.enable_size_limit}, "
+ f"max_size={self.max_item_size}, "
+ f"compression={self.enable_compression}, "
+ f"threshold={self.compression_threshold})"
+ )
+
+
+def parse_wrapper_config(config: Optional[Dict[str, Any]] = None) -> WrapperConfig:
+ """
+ Parse wrapper configuration from a dictionary.
+
+ This is a convenience function that delegates to WrapperConfig.from_dict().
+
+ Args:
+ config: Configuration dictionary
+
+ Returns:
+ WrapperConfig instance
+
+ Examples:
+ >>> config = parse_wrapper_config({"enable_statistics": True})
+ >>> print(config.enable_statistics)
+ True
+
+ Validates:
+ - Requirements 17.1: 解析 enable_statistics
+ - Requirements 17.2: 解析 enable_size_limit 和 max_item_size
+ - Requirements 17.3: 解析 enable_compression 和 compression_threshold
+ """
+ return WrapperConfig.from_dict(config)
diff --git a/src/mcpstore/core/session_manager.py b/src/mcpstore/core/session_manager.py
deleted file mode 100644
index 8b313cbd..00000000
--- a/src/mcpstore/core/session_manager.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from typing import Dict, Any, Optional, Set
-from datetime import datetime, timedelta
-import uuid
-import logging
-from fastmcp import Client
-
-logger = logging.getLogger(__name__)
-
-class AgentSession:
- """Agent 会话类"""
- def __init__(self, agent_id: str):
- self.agent_id = agent_id
- self.services: Dict[str, Client] = {} # service_name -> Client
- self.tools: Dict[str, Dict[str, Any]] = {} # tool_name -> tool_info
- self.last_active = datetime.now()
- self.created_at = datetime.now()
-
- def update_activity(self):
- """更新最后活动时间"""
- self.last_active = datetime.now()
-
- def add_service(self, service_name: str, client: Client):
- """添加服务"""
- self.services[service_name] = client
-
- def add_tool(self, tool_name: str, tool_info: Dict[str, Any], service_name: str):
- """添加工具"""
- self.tools[tool_name] = {
- **tool_info,
- "service_name": service_name
- }
-
- def get_service_for_tool(self, tool_name: str) -> Optional[str]:
- """获取工具对应的服务名"""
- return self.tools.get(tool_name, {}).get("service_name")
-
- def get_all_tools(self) -> Dict[str, Dict[str, Any]]:
- """获取所有工具信息"""
- return self.tools
-
-class SessionManager:
- """会话管理器"""
- def __init__(self, session_timeout: int = 3600):
- self.sessions: Dict[str, AgentSession] = {}
- self.session_timeout = timedelta(seconds=session_timeout)
-
- def create_session(self, agent_id: Optional[str] = None) -> AgentSession:
- """创建新会话"""
- if not agent_id:
- agent_id = str(uuid.uuid4())
-
- session = AgentSession(agent_id)
- self.sessions[agent_id] = session
- logger.info(f"Created new session for agent {agent_id}")
- return session
-
- def get_session(self, agent_id: str) -> Optional[AgentSession]:
- """获取会话"""
- session = self.sessions.get(agent_id)
- if session:
- # 检查会话是否过期
- if datetime.now() - session.last_active > self.session_timeout:
- logger.info(f"Session expired for agent {agent_id}")
- del self.sessions[agent_id]
- return None
- session.update_activity()
- return session
-
- def get_or_create_session(self, agent_id: Optional[str] = None) -> AgentSession:
- """获取或创建会话"""
- if agent_id and (session := self.get_session(agent_id)):
- return session
- return self.create_session(agent_id)
-
- def cleanup_expired_sessions(self):
- """清理过期会话"""
- now = datetime.now()
- expired = [
- agent_id for agent_id, session in self.sessions.items()
- if now - session.last_active > self.session_timeout
- ]
- for agent_id in expired:
- del self.sessions[agent_id]
- logger.info(f"Cleaned up expired session for agent {agent_id}")
diff --git a/src/mcpstore/core/store.py b/src/mcpstore/core/store.py
deleted file mode 100644
index 1808e172..00000000
--- a/src/mcpstore/core/store.py
+++ /dev/null
@@ -1,702 +0,0 @@
-from mcpstore.core.orchestrator import MCPOrchestrator
-from mcpstore.core.registry import ServiceRegistry
-from mcpstore.plugins.json_mcp import MCPConfig
-from mcpstore.core.client_manager import ClientManager
-from mcpstore.core.session_manager import SessionManager
-from mcpstore.core.models.service import (
- RegisterRequestUnion, JsonRegistrationResponse, JsonUpdateRequest, JsonConfigResponse,
- ServiceInfo, ServicesResponse, TransportType, ServiceInfoResponse,
- ServiceRegistrationResult
-)
-from mcpstore.core.models.client import ClientRegistrationResponse
-from mcpstore.core.models.tool import (
- ToolExecutionResponse, ToolInfo, ToolsResponse, ToolExecutionRequest
-)
-import logging
-from typing import Optional, List, Dict, Any, Union
-from .context import MCPStoreContext
-
-logger = logging.getLogger(__name__)
-
-class MCPStore:
- """
- MCPStore - 智能体工具服务商店
- 提供上下文切换的入口和通用操作
- """
- def __init__(self, orchestrator: MCPOrchestrator, config: MCPConfig):
- self.orchestrator = orchestrator
- self.config = config
- self.registry = orchestrator.registry
- self.client_manager = orchestrator.client_manager
- self.session_manager = orchestrator.session_manager
- self.logger = logging.getLogger(__name__)
- self._context_cache: Dict[str, MCPStoreContext] = {}
- self._store_context = self._create_store_context()
-
- def _create_store_context(self) -> MCPStoreContext:
- """创建商店级别的上下文"""
- return MCPStoreContext(self)
-
- @staticmethod
- def setup_store():
- config = MCPConfig()
- registry = ServiceRegistry()
- orchestrator = MCPOrchestrator(config.load_config(), registry)
- return MCPStore(orchestrator, config)
-
- def _create_agent_context(self, agent_id: str) -> MCPStoreContext:
- """创建agent级别的上下文"""
- return MCPStoreContext(self, agent_id)
-
- def for_store(self) -> MCPStoreContext:
- """获取商店级别的上下文"""
- # main_client 作为 store agent_id
- return self._store_context
-
- def for_agent(self, agent_id: str) -> MCPStoreContext:
- """获取agent级别的上下文(带缓存)"""
- if agent_id not in self._context_cache:
- self._context_cache[agent_id] = self._create_agent_context(agent_id)
- return self._context_cache[agent_id]
-
- async def register_service(self, payload: RegisterRequestUnion, agent_id: Optional[str] = None) -> Dict[str, str]:
- """重构:注册服务,支持批量 service_names 注册"""
- service_names = getattr(payload, 'service_names', None)
- if not service_names:
- raise ValueError("payload 必须包含 service_names 字段")
- results = {}
- agent_key = agent_id or self.client_manager.main_client_id
- for name in service_names:
- success, msg = await self.orchestrator.connect_service(name)
- if not success:
- results[name] = f"连接失败: {msg}"
- continue
- session = self.registry.get_session(agent_key, name)
- if not session:
- results[name] = "未能获取 session"
- continue
- tools = []
- try:
- tools = await session.list_tools() if hasattr(session, 'list_tools') else []
- except Exception as e:
- results[name] = f"获取工具失败: {e}"
- continue
- added_tools = self.registry.add_service(agent_key, name, session, [(tool['name'], tool) for tool in tools])
- results[name] = f"注册成功,工具数: {len(added_tools)}"
- return results
-
- async def register_json_service(self, client_id: Optional[str] = None, service_names: Optional[List[str]] = None) -> JsonRegistrationResponse:
- """
- 批量注册服务,支持多种场景:
- 1. Store 全量注册:client_id == main_client_id,不指定 service_names
- 2. Agent 指定服务注册:提供 client_id 和 service_names
- 3. 临时注册:不提供 client_id,但提供 service_names
- 4. 默认全量注册:既不提供 client_id 也不提供 service_names
-
- Args:
- client_id: 客户端ID,可选
- service_names: 服务名称列表,可选
-
- Returns:
- JsonRegistrationResponse: 注册结果
- """
- try:
- # 重新加载配置以确保使用最新配置
- all_services = self.config.load_config().get("mcpServers", {})
-
- # 情况1: Store 全量注册
- if client_id and client_id == self.client_manager.main_client_id and not service_names:
- print(f"[INFO][register_json_service] STORE模式-全量注册,client_id: {client_id}")
- agent_id = self.client_manager.main_client_id
- registered_client_ids = []
- registered_services = []
-
- for name in all_services.keys():
- try:
- new_client_id = self.client_manager.generate_client_id()
- client_config = {"mcpServers": {name: all_services[name]}}
- self.client_manager.save_client_config(new_client_id, client_config)
- self.client_manager.add_agent_client_mapping(agent_id, new_client_id)
- await self.orchestrator.register_json_services(client_config, client_id=new_client_id)
- registered_client_ids.append(new_client_id)
- registered_services.append(name)
- print(f"[INFO][register_json_service] 成功注册服务: {name}")
- except Exception as e:
- print(f"[ERROR][register_json_service] 注册服务 {name} 失败: {e}")
- continue
-
- return JsonRegistrationResponse(
- client_id=agent_id,
- service_names=registered_services,
- config={"client_ids": registered_client_ids, "services": registered_services}
- )
-
- # 情况2: 临时注册(不提供client_id但提供service_names)
- elif not client_id and service_names:
- print(f"[INFO][register_json_service] 临时注册模式,services: {service_names}")
- config = self.orchestrator.create_client_config_from_names(service_names)
- import time; agent_id = f"agent_{int(time.time() * 1000)}"
- results = await self.orchestrator.register_json_services(config)
- return JsonRegistrationResponse(
- client_id=agent_id,
- service_names=list(results.get("services", {}).keys()),
- config=config
- )
-
- # 情况3: 默认全量注册
- elif not client_id and not service_names:
- print("[INFO][register_json_service] 默认全量注册")
- return await self.register_json_service(client_id=self.client_manager.main_client_id)
-
- # 情况4: Agent 指定服务注册
- else:
- print(f"[INFO][register_json_service] AGENT模式-指定服务注册,client_id: {client_id}, services: {service_names}")
- agent_id = client_id
- registered_client_ids = []
- registered_services = []
-
- for name in service_names or []:
- try:
- if name not in all_services:
- print(f"[WARN][register_json_service] 服务 {name} 未在全局配置中找到,跳过")
- continue
-
- new_client_id = self.client_manager.generate_client_id()
- client_config = {"mcpServers": {name: all_services[name]}}
- self.client_manager.save_client_config(new_client_id, client_config)
- self.client_manager.add_agent_client_mapping(agent_id, new_client_id)
- await self.orchestrator.register_json_services(client_config, client_id=new_client_id)
- registered_client_ids.append(new_client_id)
- registered_services.append(name)
- print(f"[INFO][register_json_service] 成功注册服务: {name}")
- except Exception as e:
- print(f"[ERROR][register_json_service] 注册服务 {name} 失败: {e}")
- continue
-
- return JsonRegistrationResponse(
- client_id=agent_id,
- service_names=registered_services,
- config={"client_ids": registered_client_ids, "services": registered_services}
- )
-
- except Exception as e:
- print(f"[ERROR][register_json_service] 服务注册失败: {e}")
- return JsonRegistrationResponse(
- client_id=client_id or self.client_manager.main_client_id,
- service_names=[],
- config={}
- )
-
- async def update_json_service(self, payload: JsonUpdateRequest) -> JsonRegistrationResponse:
- """更新服务配置,等价于 PUT /register/json"""
- results = await self.orchestrator.register_json_services(
- config=payload.config,
- client_id=payload.client_id
- )
- return JsonRegistrationResponse(
- client_id=results.get("client_id", payload.client_id or "main_client"),
- service_names=list(results.get("services", {}).keys()),
- config=payload.config
- )
-
- def get_json_config(self, client_id: Optional[str] = None) -> JsonConfigResponse:
- """查询服务配置,等价于 GET /register/json"""
- if not client_id or client_id == self.client_manager.main_client_id:
- config = self.config.load_config()
- return JsonConfigResponse(
- client_id=self.client_manager.main_client_id,
- config=config
- )
- else:
- config = self.client_manager.get_client_config(client_id)
- if not config:
- raise ValueError(f"Client configuration not found: {client_id}")
- return JsonConfigResponse(
- client_id=client_id,
- config=config
- )
-
- async def process_tool_request(self, request: ToolExecutionRequest) -> ToolExecutionResponse:
- """
- 处理工具执行请求
- - 验证工具名称格式
- - 转发请求到 orchestrator 执行
-
- Args:
- request: 工具执行请求
-
- Returns:
- ToolExecutionResponse: 工具执行响应
- """
- try:
- # 从工具名称中提取服务名称
- if "_" not in request.tool_name:
- raise ValueError(f"Invalid tool name format: {request.tool_name}. Expected format: service_toolname")
-
- service_name = request.tool_name.split("_")[0]
-
- # 执行工具
- result = await self.orchestrator.execute_tool(
- service_name=service_name,
- tool_name=request.tool_name,
- parameters=request.args,
- agent_id=request.agent_id
- )
-
- return ToolExecutionResponse(
- success=True,
- result=result
- )
- except Exception as e:
- logger.error(f"Tool execution failed: {e}")
- return ToolExecutionResponse(
- success=False,
- error=str(e)
- )
-
- def register_clients(self, client_configs: Dict[str, Any]) -> ClientRegistrationResponse:
- """注册客户端,等价于 /register_clients"""
- # 这里只是示例,具体实现需根据 client_manager 逻辑完善
- for client_id, config in client_configs.items():
- self.client_manager.save_client_config(client_id, config)
- return ClientRegistrationResponse(status="success", client_ids=list(client_configs.keys()))
-
- async def get_health_status(self, id: Optional[str] = None, agent_mode: bool = False) -> Dict[str, Any]:
- """
- 获取服务健康状态:
- - store未传id 或 id==main_client:聚合 main_client 下所有 client_id 的服务健康状态
- - store传普通 client_id:只查该 client_id 下的服务健康状态
- - agent级别:聚合 agent_id 下所有 client_id 的服务健康状态;如果 id 不是 agent_id,尝试作为 client_id 查
- """
- from mcpstore.core.client_manager import ClientManager
- client_manager: ClientManager = self.client_manager
- services = []
- # 1. store未传id 或 id==main_client,聚合 main_client 下所有 client_id 的服务健康状态
- if not agent_mode and (not id or id == self.client_manager.main_client_id):
- client_ids = client_manager.get_agent_clients(self.client_manager.main_client_id)
- for client_id in client_ids:
- service_names = self.registry.get_all_service_names(client_id)
- for name in service_names:
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, client_id)
- service_status = {
- "name": name,
- "url": config.get("url", ""),
- "transport_type": config.get("transport", ""),
- "status": "healthy" if is_healthy else "unhealthy",
- "command": config.get("command"),
- "args": config.get("args"),
- "package_name": config.get("package_name")
- }
- services.append(service_status)
- return {
- "orchestrator_status": "running",
- "active_services": len(services),
- "services": services
- }
- # 2. store传普通 client_id,只查该 client_id 下的服务健康状态
- if not agent_mode and id:
- if id == self.client_manager.main_client_id:
- return {
- "orchestrator_status": "running",
- "active_services": 0,
- "services": []
- }
- service_names = self.registry.get_all_service_names(id)
- for name in service_names:
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, id)
- service_status = {
- "name": name,
- "url": config.get("url", ""),
- "transport_type": config.get("transport", ""),
- "status": "healthy" if is_healthy else "unhealthy",
- "command": config.get("command"),
- "args": config.get("args"),
- "package_name": config.get("package_name")
- }
- services.append(service_status)
- return {
- "orchestrator_status": "running",
- "active_services": len(services),
- "services": services
- }
- # 3. agent级别,聚合 agent_id 下所有 client_id 的服务健康状态;如果 id 不是 agent_id,尝试作为 client_id 查
- if agent_mode and id:
- client_ids = client_manager.get_agent_clients(id)
- if client_ids:
- for client_id in client_ids:
- service_names = self.registry.get_all_service_names(client_id)
- for name in service_names:
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, client_id)
- service_status = {
- "name": name,
- "url": config.get("url", ""),
- "transport_type": config.get("transport", ""),
- "status": "healthy" if is_healthy else "unhealthy",
- "command": config.get("command"),
- "args": config.get("args"),
- "package_name": config.get("package_name")
- }
- services.append(service_status)
- return {
- "orchestrator_status": "running",
- "active_services": len(services),
- "services": services
- }
- else:
- service_names = self.registry.get_all_service_names(id)
- for name in service_names:
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, id)
- service_status = {
- "name": name,
- "url": config.get("url", ""),
- "transport_type": config.get("transport", ""),
- "status": "healthy" if is_healthy else "unhealthy",
- "command": config.get("command"),
- "args": config.get("args"),
- "package_name": config.get("package_name")
- }
- services.append(service_status)
- return {
- "orchestrator_status": "running",
- "active_services": len(services),
- "services": services
- }
- return {
- "orchestrator_status": "running",
- "active_services": 0,
- "services": []
- }
-
- async def get_service_info(self, name: str, agent_id: Optional[str] = None) -> ServiceInfoResponse:
- """
- 获取服务详细信息:
- - 未传 agent_id:在 main_client 下所有 client_id 中查找服务
- - 传 agent_id:在该 agent_id 下所有 client_id 中查找服务
- """
- from mcpstore.core.client_manager import ClientManager
- client_manager: ClientManager = self.client_manager
-
- # 获取要查找的 client_ids
- if not agent_id:
- client_ids = client_manager.get_agent_clients(self.client_manager.main_client_id)
- else:
- client_ids = client_manager.get_agent_clients(agent_id)
-
- # 在所有相关的 client 中查找服务
- for client_id in client_ids:
- if self.registry.has_service(client_id, name):
- # 获取服务配置
- config = self.config.get_service_config(name) or {}
- service_tools = self.registry.get_tools_for_service(client_id, name)
-
- # 获取工具详细信息
- detailed_tools = []
- for tool_name in service_tools:
- tool_info = self.registry._get_detailed_tool_info(client_id, tool_name)
- if tool_info:
- detailed_tools.append(tool_info)
-
- # 获取服务健康状态
- is_healthy = await self.orchestrator.is_service_healthy(name, client_id)
-
- # 构建服务信息
- service_info = ServiceInfo(
- url=config.get("url", ""),
- name=name,
- transport_type=self._infer_transport_type(config),
- status="healthy" if is_healthy else "unhealthy",
- tool_count=len(service_tools),
- keep_alive=config.get("keep_alive", False),
- working_dir=config.get("working_dir"),
- env=config.get("env"),
- last_heartbeat=self.registry.get_last_heartbeat(client_id, name),
- command=config.get("command"),
- args=config.get("args"),
- package_name=config.get("package_name")
- )
-
- return ServiceInfoResponse(
- service=service_info,
- tools=detailed_tools,
- connected=True
- )
-
- return ServiceInfoResponse(
- service=None,
- tools=[],
- connected=False
- )
-
- def _infer_transport_type(self, service_config: Dict[str, Any]) -> TransportType:
- """推断服务的传输类型"""
- if not service_config:
- return TransportType.STREAMABLE_HTTP
-
- # 优先使用 transport 字段
- transport = service_config.get("transport")
- if transport:
- try:
- return TransportType(transport)
- except ValueError:
- pass
-
- # 其次根据 url 判断
- if service_config.get("url"):
- return TransportType.STREAMABLE_HTTP
-
- # 根据 command/args 判断
- cmd = (service_config.get("command") or "").lower()
- args = " ".join(service_config.get("args", [])).lower()
-
- if "python" in cmd or ".py" in args:
- return TransportType.STDIO_PYTHON
- if "node" in cmd or ".js" in args:
- return TransportType.STDIO_NODE
- if "uvx" in cmd:
- return TransportType.STDIO # 使用通用的STDIO类型
- if "npx" in cmd:
- return TransportType.STDIO # 使用通用的STDIO类型
-
- return TransportType.STREAMABLE_HTTP
-
- async def list_services(self, id: Optional[str] = None, agent_mode: bool = False) -> List[ServiceInfo]:
- """
- 获取服务列表:
- - store未传id 或 id==main_client:聚合 main_client 下所有 client_id 的服务
- - store传普通 client_id:只查该 client_id 下的服务
- - agent级别:聚合 agent_id 下所有 client_id 的服务;如果 id 不是 agent_id,尝试作为 client_id 查
- """
- from mcpstore.core.client_manager import ClientManager
- client_manager: ClientManager = self.client_manager
- services_info = []
- # 1. store未传id 或 id==main_client,聚合 main_client 下所有 client_id 的服务
- if not agent_mode and (not id or id == self.client_manager.main_client_id):
- client_ids = client_manager.get_agent_clients(self.client_manager.main_client_id)
- for client_id in client_ids:
- service_names = self.registry.get_all_service_names(client_id)
- for name in service_names:
- details = self.registry.get_service_details(client_id, name)
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, client_id)
- service_info = ServiceInfo(
- url=config.get("url", ""),
- name=name,
- transport_type=self._infer_transport_type(config),
- status="healthy" if is_healthy else "unhealthy",
- tool_count=details.get("tool_count", 0),
- keep_alive=config.get("keep_alive", False),
- working_dir=config.get("working_dir"),
- env=config.get("env"),
- last_heartbeat=self.registry.get_last_heartbeat(client_id, name),
- command=config.get("command"),
- args=config.get("args"),
- package_name=config.get("package_name")
- )
- services_info.append(service_info)
- return services_info
- # 2. store传普通 client_id,只查该 client_id 下的服务
- if not agent_mode and id:
- if id == self.client_manager.main_client_id:
- # 已在上面聚合分支处理,这里直接返回空
- return services_info
- service_names = self.registry.get_all_service_names(id)
- for name in service_names:
- details = self.registry.get_service_details(id, name)
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, id)
- service_info = ServiceInfo(
- url=config.get("url", ""),
- name=name,
- transport_type=self._infer_transport_type(config),
- status="healthy" if is_healthy else "unhealthy",
- tool_count=details.get("tool_count", 0),
- keep_alive=config.get("keep_alive", False),
- working_dir=config.get("working_dir"),
- env=config.get("env"),
- last_heartbeat=self.registry.get_last_heartbeat(id, name),
- command=config.get("command"),
- args=config.get("args"),
- package_name=config.get("package_name")
- )
- services_info.append(service_info)
- return services_info
- # 3. agent级别,聚合 agent_id 下所有 client_id 的服务;如果 id 不是 agent_id,尝试作为 client_id 查
- if agent_mode and id:
- client_ids = client_manager.get_agent_clients(id)
- if client_ids:
- for client_id in client_ids:
- service_names = self.registry.get_all_service_names(client_id)
- for name in service_names:
- details = self.registry.get_service_details(client_id, name)
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, client_id)
- service_info = ServiceInfo(
- url=config.get("url", ""),
- name=name,
- transport_type=self._infer_transport_type(config),
- status="healthy" if is_healthy else "unhealthy",
- tool_count=details.get("tool_count", 0),
- keep_alive=config.get("keep_alive", False),
- working_dir=config.get("working_dir"),
- env=config.get("env"),
- last_heartbeat=self.registry.get_last_heartbeat(client_id, name),
- command=config.get("command"),
- args=config.get("args"),
- package_name=config.get("package_name")
- )
- services_info.append(service_info)
- return services_info
- else:
- service_names = self.registry.get_all_service_names(id)
- for name in service_names:
- details = self.registry.get_service_details(id, name)
- config = self.config.get_service_config(name) or {}
- is_healthy = await self.orchestrator.is_service_healthy(name, id)
- service_info = ServiceInfo(
- url=config.get("url", ""),
- name=name,
- transport_type=self._infer_transport_type(config),
- status="healthy" if is_healthy else "unhealthy",
- tool_count=details.get("tool_count", 0),
- keep_alive=config.get("keep_alive", False),
- working_dir=config.get("working_dir"),
- env=config.get("env"),
- last_heartbeat=self.registry.get_last_heartbeat(id, name),
- command=config.get("command"),
- args=config.get("args"),
- package_name=config.get("package_name")
- )
- services_info.append(service_info)
- return services_info
- return services_info
-
- async def list_tools(self, id: Optional[str] = None, agent_mode: bool = False) -> List[ToolInfo]:
- """
- 列出工具列表:
- - store未传id 或 id==main_client:聚合 main_client 下所有 client_id 的工具
- - store传普通 client_id:只查该 client_id 下的工具
- - agent级别:聚合 agent_id 下所有 client_id 的工具;如果 id 不是 agent_id,尝试作为 client_id 查
- """
- from mcpstore.core.client_manager import ClientManager
- client_manager: ClientManager = self.client_manager
- tools = []
- # 1. store未传id 或 id==main_client,聚合 main_client 下所有 client_id 的工具
- if not agent_mode and (not id or id == self.client_manager.main_client_id):
- client_ids = client_manager.get_agent_clients(self.client_manager.main_client_id)
- for client_id in client_ids:
- tool_dicts = self.registry.get_all_tool_info(client_id)
- for tool in tool_dicts:
- tools.append(ToolInfo(
- name=tool.get("name", ""),
- description=tool.get("description", ""),
- service_name=tool.get("service_name", ""),
- client_id=tool.get("client_id", ""),
- inputSchema=tool.get("inputSchema", {})
- ))
- return tools
- # 2. store传普通 client_id,只查该 client_id 下的工具
- if not agent_mode and id:
- if id == self.client_manager.main_client_id:
- return tools
- tool_dicts = self.registry.get_all_tool_info(id)
- for tool in tool_dicts:
- tools.append(ToolInfo(
- name=tool.get("name", ""),
- description=tool.get("description", ""),
- service_name=tool.get("service_name", ""),
- client_id=tool.get("client_id", ""),
- inputSchema=tool.get("inputSchema", {})
- ))
- return tools
- # 3. agent级别,聚合 agent_id 下所有 client_id 的工具;如果 id 不是 agent_id,尝试作为 client_id 查
- if agent_mode and id:
- client_ids = client_manager.get_agent_clients(id)
- if client_ids:
- for client_id in client_ids:
- tool_dicts = self.registry.get_all_tool_info(client_id)
- for tool in tool_dicts:
- tools.append(ToolInfo(
- name=tool.get("name", ""),
- description=tool.get("description", ""),
- service_name=tool.get("service_name", ""),
- client_id=tool.get("client_id", ""),
- inputSchema=tool.get("inputSchema", {})
- ))
- return tools
- else:
- tool_dicts = self.registry.get_all_tool_info(id)
- for tool in tool_dicts:
- tools.append(ToolInfo(
- name=tool.get("name", ""),
- description=tool.get("description", ""),
- service_name=tool.get("service_name", ""),
- client_id=tool.get("client_id", ""),
- inputSchema=tool.get("inputSchema", {})
- ))
- return tools
- return tools
-
- async def use_tool(self, tool_name: str, args: Dict[str, Any]) -> Any:
- """
- 使用工具(通用接口)
-
- Args:
- tool_name: 工具名称,格式为 service_toolname
- args: 工具参数
-
- Returns:
- Any: 工具执行结果
- """
- from mcpstore.core.models.tool import ToolExecutionRequest
-
- # 构造请求
- request = ToolExecutionRequest(
- tool_name=tool_name,
- args=args
- )
-
- # 处理工具请求
- return await self.process_tool_request(request)
-
- async def _add_service(self, service_names: List[str], agent_id: Optional[str]) -> bool:
- """内部方法:批量添加服务,store级别支持全量注册,agent级别支持指定服务注册"""
- # store级别
- if agent_id is None:
- if not service_names:
- # 全量注册
- resp = await self.register_json_service()
- return bool(resp and resp.service_names)
- else:
- # 支持单独添加服务
- resp = await self.register_json_service(service_names=service_names)
- return bool(resp and resp.service_names)
- # agent级别
- else:
- if service_names:
- resp = await self.register_json_service(client_id=agent_id, service_names=service_names)
- return bool(resp and resp.service_names)
- else:
- self.logger.warning("Agent级别添加服务时必须指定service_names")
- return False
-
- async def add_service(self, service_names: List[str], agent_id: Optional[str] = None) -> bool:
- context = self.for_agent(agent_id) if agent_id else self.for_store()
- return await context.add_service(service_names)
-
- def check_services(self, agent_id: Optional[str] = None) -> Dict[str, str]:
- """兼容旧版API"""
- context = self.for_agent(agent_id) if agent_id else self.for_store()
- return context.check_services()
-
- def show_mcpjson(self) -> Dict[str, Any]:
- """
- 直接读取并返回 mcp.json 文件的内容
-
- Returns:
- Dict[str, Any]: mcp.json 文件的内容
- """
- return self.config.load_config()
diff --git a/src/mcpstore/core/store/__init__.py b/src/mcpstore/core/store/__init__.py
new file mode 100644
index 00000000..bf5a4e80
--- /dev/null
+++ b/src/mcpstore/core/store/__init__.py
@@ -0,0 +1,11 @@
+# MCPStore composition and external exports (latest, single-path architecture)
+
+from .client_manager import ClientManager
+from .composed_store import MCPStore
+from .setup_manager import StoreSetupManager
+
+# Expose only authoritative setup_store entry points
+MCPStore.setup_store = staticmethod(StoreSetupManager.setup_store)
+MCPStore.setup_store_async = staticmethod(StoreSetupManager.setup_store_async)
+
+__all__ = ['MCPStore', 'ClientManager']
diff --git a/src/mcpstore/core/store/api_server.py b/src/mcpstore/core/store/api_server.py
new file mode 100644
index 00000000..5c4c15a0
--- /dev/null
+++ b/src/mcpstore/core/store/api_server.py
@@ -0,0 +1,142 @@
+"""
+API 服务器模块
+负责处理 MCPStore 的 API 服务器启动功能
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class APIServerMixin:
+ """API 服务器 Mixin"""
+
+ def start_api_server(
+ self,
+ host: str = "0.0.0.0",
+ port: int = 18200,
+ reload: bool = False,
+ log_level: str = "info",
+ auto_open_browser: bool = False,
+ show_startup_info: bool = True,
+ url_prefix: str = "" # New: URL prefix parameter
+ ) -> None:
+ """
+ 启动 API 服务器(改进版)
+
+ 这个方法会启动一个 HTTP API 服务器,提供 RESTful 接口来访问当前 MCPStore 实例的功能。
+ 服务器会自动使用当前 store 的配置和数据空间。
+
+ Args:
+ host: 服务器监听地址,默认 "0.0.0.0"(所有网络接口)
+ port: 服务器监听端口,默认 18200
+ reload: 是否启用自动重载(开发模式),默认 False
+ log_level: 日志级别,可选值: "critical", "error", "warning", "info", "debug", "trace"
+ auto_open_browser: 是否自动打开浏览器,默认 False
+ show_startup_info: 是否显示启动信息,默认 True
+ url_prefix: URL 前缀,如 "/api/v1"。默认为空(无前缀)
+
+ Note:
+ - 此方法会阻塞当前线程直到服务器停止
+ - 使用 Ctrl+C 可以优雅地停止服务器
+ - 如果使用了数据空间,API 会自动使用对应的工作空间
+ - 本地服务的子进程会被正确管理和清理
+
+ Example:
+ # 基本使用(无前缀)
+ store = MCPStore.setup_store()
+ store.start_api_server()
+ # 访问: http://localhost:18200/for_store/list_services
+
+ # 使用 URL 前缀
+ store.start_api_server(url_prefix="/api/v1")
+ # 访问: http://localhost:18200/api/v1/for_store/list_services
+
+ # 开发模式
+ store.start_api_server(reload=True, auto_open_browser=True)
+
+ # 自定义配置
+ store.start_api_server(
+ host="localhost",
+ port=8080,
+ log_level="debug",
+ url_prefix="/api"
+ )
+ """
+ try:
+ import uvicorn
+ import webbrowser
+ from pathlib import Path
+
+ logger.info(f"Starting API server for store: data_space={self.is_using_data_space()}")
+
+ if show_startup_info:
+ print("[START] Starting MCPStore API Server...")
+ print(f" Host: {host}:{port}")
+
+ if url_prefix:
+ print(f" URL Prefix: {url_prefix}")
+ base_url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
+ print(f" Example: {base_url}{url_prefix}/for_store/list_services")
+ else:
+ base_url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
+ print(f" Example: {base_url}/for_store/list_services")
+
+ if self.is_using_data_space():
+ workspace_dir = self.get_workspace_dir()
+ print(f" Data Space: {workspace_dir}")
+ print(f" MCP Config: {self.config.json_path}")
+ else:
+ print(f" MCP Config: {self.config.json_path}")
+
+ if reload:
+ print(" Mode: Development (auto-reload enabled)")
+ else:
+ print(" Mode: Production")
+
+ print(" Press Ctrl+C to stop")
+ print()
+
+ # 自动打开浏览器
+ if auto_open_browser:
+ import threading
+ import time
+
+ def open_browser():
+ time.sleep(2) # 等待服务器启动
+ try:
+ base_url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
+ doc_url = f"{base_url}{url_prefix}/docs" if url_prefix else f"{base_url}/docs"
+ webbrowser.open(doc_url)
+ except Exception as e:
+ if show_startup_info:
+ print(f"[WARNING] Failed to open browser: {e}")
+
+ threading.Thread(target=open_browser, daemon=True).start()
+
+ # Create app instance and pass current store and URL prefix
+ # Note: 延迟导入避免 core 层在模块加载时就依赖 scripts 层
+ from mcpstore.scripts.api_app import create_app
+ app = create_app(store=self, url_prefix=url_prefix)
+
+ # 启动 API 服务器
+ uvicorn.run(
+ app,
+ host=host,
+ port=port,
+ reload=reload,
+ log_level=log_level
+ )
+
+ except KeyboardInterrupt:
+ if show_startup_info:
+ print("\n[STOPPED] Server stopped by user")
+ except ImportError as e:
+ raise RuntimeError(
+ "Failed to import required dependencies for API server. "
+ "Please install uvicorn: pip install uvicorn"
+ ) from e
+ except Exception as e:
+ if show_startup_info:
+ print(f" Failed to start server: {e}")
+ raise
diff --git a/src/mcpstore/core/store/base_store.py b/src/mcpstore/core/store/base_store.py
new file mode 100644
index 00000000..68604714
--- /dev/null
+++ b/src/mcpstore/core/store/base_store.py
@@ -0,0 +1,208 @@
+"""
+Base MCPStore class
+Contains core initialization logic and basic properties
+"""
+
+import logging
+import threading
+from typing import Dict, Optional
+from weakref import WeakValueDictionary
+
+from mcpstore.config.json_config import MCPConfig
+from mcpstore.core.configuration.unified_config import UnifiedConfigManager
+from mcpstore.core.context import MCPStoreContext
+from mcpstore.core.orchestrator import MCPOrchestrator
+
+logger = logging.getLogger(__name__)
+
+
+class BaseMCPStore:
+ """
+ MCPStore - Intelligent Agent Tool Service Store
+ Base class containing core initialization and properties
+ """
+
+ def __init__(self, orchestrator: MCPOrchestrator, config: MCPConfig,
+ tool_record_max_file_size: int = 30, tool_record_retention_days: int = 7):
+ self.orchestrator = orchestrator
+ self.config = config
+ self.registry = orchestrator.registry
+ self.client_manager = orchestrator.client_manager
+
+ # [FIX] Add LocalServiceManager access attribute
+ self.local_service_manager = orchestrator.local_service_manager
+ self.session_manager = orchestrator.session_manager
+ self.logger = logging.getLogger(__name__)
+
+ # Tool recording configuration
+ self.tool_record_max_file_size = tool_record_max_file_size
+ self.tool_record_retention_days = tool_record_retention_days
+
+ # Unified configuration manager (pass instance reference)
+ self._unified_config = UnifiedConfigManager(mcp_config=config)
+
+
+ # Set unified config to registry for JSON persistence
+ self.registry.set_unified_config(self._unified_config)
+
+ self._context_cache: Dict[str, MCPStoreContext] = {}
+ self._store_context = self._create_store_context()
+
+ # AgentProxy caching system for unified agent access
+ self._agent_proxy_cache: WeakValueDictionary[str, 'AgentProxy'] = WeakValueDictionary()
+ self._agent_cache_lock: threading.RLock = threading.RLock()
+
+ # Data space manager (optional, only set when using data spaces)
+ self._data_space_manager = None
+
+ # [NEW] Cache manager
+
+ # Cache manager
+ from mcpstore.core.registry.cache_manager import ServiceCacheManager, CacheTransactionManager
+ self.cache_manager = ServiceCacheManager(self.registry, self.orchestrator.lifecycle_manager)
+ self.transaction_manager = CacheTransactionManager(self.registry)
+
+ # Write locks: per-agent atomic write areas
+ from mcpstore.core.registry.agent_locks import AgentLocks
+ self.agent_locks = AgentLocks()
+
+ # [已删除] SmartCacheQuery 接口
+ # 原因: 功能冗余,可通过 registry 直接实现
+
+ # 事件驱动架构: 初始化 ServiceContainer
+ from mcpstore.core.infrastructure.container import ServiceContainer
+ from mcpstore.core.configuration.config_processor import ConfigProcessor
+
+ self.container = ServiceContainer(
+ registry=self.registry,
+ agent_locks=self.agent_locks,
+ config_manager=self._unified_config,
+ config_processor=ConfigProcessor,
+ local_service_manager=self.local_service_manager,
+ global_agent_store_id=self.client_manager.global_agent_store_id,
+ enable_event_history=False # Disable event history in production
+ )
+
+ # ToolSetManager 已废弃,工具可用性统一使用 StateManager
+ # 工具状态存储在状态层: default:state:service_status
+
+ # [UNIFIED] Point orchestrator.lifecycle_manager to container's lifecycle_manager
+ try:
+ self.orchestrator.lifecycle_manager = self.container.lifecycle_manager
+ except Exception as e:
+ logger.debug(f"Link lifecycle_manager failed: {e}")
+
+ # [UNIFIED] Initialize content_manager after lifecycle_manager is set
+ try:
+ from mcpstore.core.lifecycle.content_manager import ServiceContentManager
+ self.orchestrator.content_manager = ServiceContentManager(self.orchestrator)
+ logger.info("ServiceContentManager initialization successful")
+ except Exception as e:
+ logger.warning(f"ServiceContentManager initialization failed: {e}")
+
+ # Break circular dependency: pass container and context_factory to orchestrator
+ # instead of letting orchestrator hold store reference (must be after container initialization)
+ orchestrator.container = self.container
+ orchestrator._context_factory = lambda: self.for_store()
+ # Ensure sync manager can reference store for batch registration path
+ try:
+ orchestrator.store = self
+ except Exception:
+ pass
+
+ logger.info("ServiceContainer initialized with event-driven architecture")
+
+ def _create_store_context(self) -> MCPStoreContext:
+ """Create store-level context"""
+ return MCPStoreContext(self)
+
+ def _get_or_create_agent_proxy(self, context: MCPStoreContext, agent_id: str) -> 'AgentProxy':
+ """
+ Get or create AgentProxy with unified caching.
+
+ This method ensures that the same agent_id always returns the same AgentProxy
+ instance across the entire MCPStore instance, providing true object identity
+ and consistent state management.
+
+ Args:
+ context: The MCPStoreContext to use for the agent
+ agent_id: Unique identifier for the agent
+
+ Returns:
+ AgentProxy: Cached or newly created AgentProxy instance
+ """
+ with self._agent_cache_lock:
+ # Try to get from cache first
+ cached_proxy = self._agent_proxy_cache.get(agent_id)
+ if cached_proxy is not None:
+ return cached_proxy
+
+ # Create new AgentProxy and cache it
+ from mcpstore.core.context.agent_proxy import AgentProxy
+ agent_proxy = AgentProxy(context, agent_id)
+ self._agent_proxy_cache[agent_id] = agent_proxy
+
+ return agent_proxy
+
+ def _clear_agent_proxy_cache(self, agent_id: Optional[str] = None) -> None:
+ """
+ Clear AgentProxy cache.
+
+ Args:
+ agent_id: Specific agent ID to clear, or None to clear all
+ """
+ with self._agent_cache_lock:
+ if agent_id is None:
+ self._agent_proxy_cache.clear()
+ else:
+ self._agent_proxy_cache.pop(agent_id, None)
+
+ def _get_agent_proxy_cache_stats(self) -> Dict[str, int]:
+ """
+ Get AgentProxy cache statistics.
+
+ Returns:
+ Dictionary containing cache statistics
+ """
+ with self._agent_cache_lock:
+ return {
+ 'total_cached_agents': len(self._agent_proxy_cache),
+ 'cache_lock_acquired': 1 # Simple indicator that lock is working
+ }
+
+ async def cleanup(self):
+ """
+ Cleanup resources on shutdown.
+
+ This method handles proper cleanup of Redis clients and health check tasks.
+ It follows the lifecycle management rules:
+ - Only close system-created Redis clients
+ - Do not close user-provided Redis clients
+ - Stop health check tasks gracefully
+ """
+ logger.info("Starting MCPStore cleanup...")
+
+ # Stop health check task if running
+ health_check_task = getattr(self, "_health_check_task", None)
+ if health_check_task:
+ try:
+ await health_check_task.stop()
+ logger.debug("Health check task stopped")
+ except Exception as e:
+ logger.warning(f"Error stopping health check task: {e}")
+
+ # Close system-created Redis client (but not user-provided)
+ system_redis_client = getattr(self, "_system_created_redis_client", None)
+ if system_redis_client:
+ try:
+ await system_redis_client.close()
+ logger.debug("System-created Redis client closed")
+ except Exception as e:
+ logger.warning(f"Error closing Redis client: {e}")
+
+ # Do NOT close user-provided Redis client
+ user_redis_client = getattr(self, "_user_provided_redis_client", None)
+ if user_redis_client:
+ logger.debug("User-provided Redis client not closed (managed by user)")
+
+ logger.info("MCPStore cleanup completed")
diff --git a/src/mcpstore/core/store/client_manager.py b/src/mcpstore/core/store/client_manager.py
new file mode 100644
index 00000000..194933d2
--- /dev/null
+++ b/src/mcpstore/core/store/client_manager.py
@@ -0,0 +1,39 @@
+import logging
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class ClientManager:
+ """
+ Simplified Client Manager - Single data source architecture
+
+ In the new architecture, ClientManager is only responsible for providing global_agent_store_id,
+ all configurations and mappings are managed through cache, with mcp.json as the only persistence data source.
+
+ Deprecated features (removed):
+ - Sharded file operations (client_services.json, agent_clients.json)
+ - Client configuration file read/write
+ - Agent-Client mapping file management
+ """
+
+ def __init__(self, global_agent_store_id: Optional[str] = None):
+ """
+ Initialize client manager
+
+ Args:
+ global_agent_store_id: Global Agent Store ID
+ """
+ # Single data source architecture: only need global_agent_store_id
+ self.global_agent_store_id = global_agent_store_id or self._generate_data_space_client_id()
+ logger.info(f"ClientManager initialized with global_agent_store_id: {self.global_agent_store_id}")
+
+ def _generate_data_space_client_id(self) -> str:
+ """
+ Generate global_agent_store_id
+
+ Returns:
+ str: Fixed return "global_agent_store"
+ """
+ # Store-level Agent is fixed to global_agent_store
+ return "global_agent_store"
diff --git a/src/mcpstore/core/store/composed_store.py b/src/mcpstore/core/store/composed_store.py
new file mode 100644
index 00000000..a177692a
--- /dev/null
+++ b/src/mcpstore/core/store/composed_store.py
@@ -0,0 +1,29 @@
+"""
+Composed MCPStore class
+Defines the final MCPStore by composing mixins and BaseMCPStore in one place
+"""
+from .api_server import APIServerMixin
+from .base_store import BaseMCPStore
+from .config_export_mixin import ConfigExportMixin
+from .config_management import ConfigManagementMixin
+from .context_factory import ContextFactoryMixin
+from .data_space_manager import DataSpaceManagerMixin
+from .service_query import ServiceQueryMixin
+from .setup_mixin import SetupMixin
+from .tool_operations import ToolOperationsMixin
+
+
+class MCPStore(
+ ServiceQueryMixin,
+ ToolOperationsMixin,
+ ConfigManagementMixin,
+ DataSpaceManagerMixin,
+ APIServerMixin,
+ ContextFactoryMixin,
+ SetupMixin,
+ ConfigExportMixin,
+ BaseMCPStore,
+):
+ """Final composed Store class"""
+ pass
+
diff --git a/src/mcpstore/core/store/config_export_mixin.py b/src/mcpstore/core/store/config_export_mixin.py
new file mode 100644
index 00000000..ca402083
--- /dev/null
+++ b/src/mcpstore/core/store/config_export_mixin.py
@@ -0,0 +1,227 @@
+"""
+Configuration export Mixin module
+Responsible for handling MCPStore configuration export functionality
+"""
+
+import json
+import logging
+from typing import Optional, Dict, Any
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigExportMixin:
+ """Configuration export Mixin - Contains configuration export methods"""
+
+ async def exportjson(self, filepath: Optional[str] = None) -> Dict[str, Any]:
+ """
+ Export cache data to standard MCP JSON format
+
+ This method reads all services from the cache and converts them to the
+ standard MCP JSON format with mcpServers structure. It can optionally
+ save the data to a file.
+
+ Args:
+ filepath: Optional file path to save the exported data.
+ If None, only returns the data without saving.
+
+ Returns:
+ Dictionary containing the exported data in MCP JSON format:
+ {
+ "mcpServers": {
+ "service_name": {
+ "command": "...",
+ "args": [...],
+ ...
+ },
+ ...
+ }
+ }
+
+ Example:
+ # Export to file
+ data = await store.exportjson("backup.json")
+
+ # Get data without saving
+ data = await store.exportjson()
+
+ Note:
+ Method name follows the "no underscores" naming convention for
+ a cleaner API surface.
+
+ Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 15.1, 15.2, 15.3
+ """
+ try:
+ logger.info("Starting cache data export to MCP JSON format")
+
+ # 1. Read all services from cache using registry
+ mcp_servers = {}
+
+ # Get all agent IDs
+ agent_ids = await self.registry.get_all_agent_ids_async()
+ logger.debug(f"Found {len(agent_ids)} agents in cache")
+
+ for agent_id in agent_ids:
+ # Get all client IDs for this agent - 从 pykv 获取
+ client_ids = await self.registry.get_agent_clients_async(agent_id)
+ logger.debug(f"Agent {agent_id} has {len(client_ids)} clients")
+
+ for client_id in client_ids:
+ # Get client entity (新架构:使用 services 列表)
+ client_entity = self.registry.get_client_config_from_cache(client_id)
+
+ if client_entity and isinstance(client_entity, dict):
+ services = client_entity.get("services", [])
+ # 从服务实体获取每个服务的配置
+ for service_name in services:
+ try:
+ service_info = await self.registry.get_complete_service_info_async(agent_id, service_name)
+ if not service_info or not service_info.get("config"):
+ continue
+ service_config = service_info["config"]
+
+ # For agent services, use the global name (with suffix)
+ if agent_id != self.client_manager.global_agent_store_id:
+ # Check if this is an agent service - get global name(使用异步版本,避免 AOB 事件循环冲突)
+ global_name = await self.registry.get_global_name_from_agent_service_async(
+ agent_id, service_name
+ )
+ if global_name:
+ mcp_servers[global_name] = service_config
+ else:
+ # Fallback: use service name as-is
+ mcp_servers[service_name] = service_config
+ else:
+ # Store service, use service name directly
+ mcp_servers[service_name] = service_config
+ except Exception as e:
+ logger.warning(f"Failed to get service config for {service_name}: {e}")
+ continue
+
+ # 2. Convert to standard MCP JSON format
+ export_data = {
+ "mcpServers": mcp_servers
+ }
+
+ logger.info(f"Exported {len(mcp_servers)} services from cache")
+
+ # 3. Save to file if filepath provided
+ if filepath:
+ with open(filepath, 'w', encoding='utf-8') as f:
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
+ logger.info(f"Exported data saved to {filepath}")
+
+ # 4. Return exported data dictionary
+ return export_data
+
+ except Exception as e:
+ logger.error(f"Failed to export cache data: {e}", exc_info=True)
+ raise RuntimeError(f"Cache data export failed: {e}")
+
+ async def export_to_json(self, output_path: str, include_sessions: bool = False) -> None:
+ """
+ Export configuration from cache to JSON file
+
+ Args:
+ output_path: Output JSON file path
+ include_sessions: Whether to include Session data (default False, as Session is not serializable)
+
+ Raises:
+ ValueError: If include_sessions=True (Session data is not serializable)
+ RuntimeError: If export process fails
+ """
+ if include_sessions:
+ raise ValueError(
+ "Session data cannot be exported because Session objects are not serializable. "
+ "Set include_sessions=False to export configuration without sessions."
+ )
+
+ try:
+ logger.info(f"Starting configuration export to {output_path}")
+
+ # Export configuration from cache
+ config_data = await self._export_config_from_cache()
+
+ # Write to JSON file
+ with open(output_path, 'w', encoding='utf-8') as f:
+ json.dump(config_data, f, indent=2, ensure_ascii=False)
+
+ logger.info(f"Configuration exported successfully to {output_path}")
+
+ except Exception as e:
+ logger.error(f"Failed to export configuration: {e}")
+ raise RuntimeError(f"Configuration export failed: {e}")
+
+ async def _export_config_from_cache(self) -> dict:
+ """
+ Export configuration data from cache (excluding Sessions)
+
+ Returns:
+ Configuration dictionary compatible with mcp.json format
+ """
+ try:
+ # Get all agent IDs from registry
+ agent_ids = await self._get_all_agent_ids_from_cache()
+
+ # Build mcpServers configuration
+ mcp_servers = {}
+
+ for agent_id in agent_ids:
+ # Get client IDs for this agent - 从 pykv 获取
+ client_ids = await self.registry.get_agent_clients_async(agent_id)
+
+ for client_id in client_ids:
+ # Get client entity (新架构:使用 services 列表)
+ client_entity = self.registry.get_client_config_from_cache(client_id)
+
+ if client_entity and isinstance(client_entity, dict):
+ services = client_entity.get("services", [])
+ # 从服务实体获取每个服务的配置
+ for service_name in services:
+ try:
+ service_info = await self.registry.get_complete_service_info_async(agent_id, service_name)
+ if not service_info or not service_info.get("config"):
+ continue
+ service_config = service_info["config"]
+
+ # For agent services, use the global name (with suffix)
+ if agent_id != self.client_manager.global_agent_store_id:
+ # Check if this is an agent service mapping
+ from mcpstore.core.context.agent_service_mapper import AgentServiceMapper
+ global_name = AgentServiceMapper.get_global_service_name(agent_id, service_name)
+ mcp_servers[global_name] = service_config
+ else:
+ # Store service, use service name directly
+ mcp_servers[service_name] = service_config
+ except Exception as e:
+ logger.warning(f"Failed to get service config for {service_name}: {e}")
+ continue
+
+ # Build the complete configuration
+ config = {
+ "mcpServers": mcp_servers
+ }
+
+ logger.debug(f"Exported {len(mcp_servers)} services from cache")
+
+ return config
+
+ except Exception as e:
+ logger.error(f"Failed to export configuration from cache: {e}")
+ raise
+
+ async def _get_all_agent_ids_from_cache(self) -> list:
+ """
+ Get all Agent IDs from cache
+
+ Returns:
+ List of Agent IDs
+ """
+ try:
+ # Use Registry API to get all Agent IDs from cache
+ agent_ids = await self.registry.get_all_agent_ids_async()
+ return list(agent_ids)
+
+ except Exception as e:
+ logger.error(f"Failed to get agent IDs from cache: {e}")
+ return []
diff --git a/src/mcpstore/core/store/config_management.py b/src/mcpstore/core/store/config_management.py
new file mode 100644
index 00000000..4eab9b88
--- /dev/null
+++ b/src/mcpstore/core/store/config_management.py
@@ -0,0 +1,118 @@
+"""
+Configuration management module
+Responsible for handling MCPStore configuration related functionality
+"""
+
+import logging
+from typing import Optional, Dict, Any, Union
+
+from mcpstore.core.configuration.unified_config import UnifiedConfigManager
+from mcpstore.core.models.common import ConfigResponse
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigManagementMixin:
+ """Configuration management Mixin"""
+
+ def get_unified_config(self) -> UnifiedConfigManager:
+ """Get unified configuration manager
+
+ Returns:
+ UnifiedConfigManager: Unified configuration manager instance
+ """
+ return self._unified_config
+
+ def get_json_config(self, client_id: Optional[str] = None) -> ConfigResponse:
+ """Query service configuration, equivalent to GET /register/json (optimized: use cache)"""
+ if not client_id or client_id == self.client_manager.global_agent_store_id:
+ # Use UnifiedConfigManager to read config (from cache, more efficient)
+ config = self._unified_config.get_mcp_config()
+ return ConfigResponse(
+ success=True,
+ client_id=self.client_manager.global_agent_store_id,
+ config=config
+ )
+ else:
+ config = self.client_manager.get_client_config(client_id)
+ if not config:
+ raise ValueError(f"Client configuration not found: {client_id}")
+ return ConfigResponse(
+ success=True,
+ client_id=client_id,
+ config=config
+ )
+
+ def show_mcpjson(self) -> Dict[str, Any]:
+ # TODO: Whether show_mcpjson and get_json_config have some overlap
+ """
+ Directly read and return mcp.json file content (optimized: use cache)
+
+ Returns:
+ Dict[str, Any]: Content of mcp.json file
+ """
+ # Use UnifiedConfigManager to read config (from cache, more efficient)
+ return self._unified_config.get_mcp_config()
+
+ async def _sync_discovered_agents_to_files(self, agents_discovered: set):
+ """
+ Single data source architecture: no longer sync to sharded files
+
+ In new architecture, Agent discovery only needs to update cache, all persistence done through mcp.json
+ """
+ try:
+ # logger.info(f" [SYNC_AGENTS] Single data source mode: Skip sharded file sync, discovered {len(agents_discovered)} agents")
+
+ # Single data source mode: No longer write to sharded files, only maintain cache and mcp.json
+ # logger.info(" [SYNC_AGENTS] Single data source mode: Agent discovery completed, cache updated")
+ pass
+ except Exception as e:
+ # logger.error(f" [SYNC_AGENTS] Agent sync failed: {e}")
+ raise
+
+ async def _switch_cache_backend(self, cache_config: Union["MemoryConfig", "RedisConfig", str, Dict[str, Any]]) -> None:
+ from mcpstore.config.cache_config import MemoryConfig, RedisConfig, create_kv_store_async
+
+ parsed_config = self._parse_cache_config(cache_config, MemoryConfig, RedisConfig)
+ new_kv_store = await create_kv_store_async(parsed_config, test_connection=True)
+ await self.registry.switch_backend(new_kv_store)
+
+ def _parse_cache_config(
+ self,
+ cache_config: Union["MemoryConfig", "RedisConfig", str, Dict[str, Any]],
+ memory_cls,
+ redis_cls,
+ ) -> Union["MemoryConfig", "RedisConfig"]:
+ if isinstance(cache_config, (memory_cls, redis_cls)):
+ return cache_config
+
+ if isinstance(cache_config, str):
+ if cache_config.lower() == "memory":
+ return memory_cls()
+ raise ValueError(f"Unsupported cache type string: {cache_config}")
+
+ if isinstance(cache_config, dict):
+ cache_type = str(cache_config.get("type", "")).lower()
+
+ if cache_type == "memory":
+ return memory_cls(
+ max_size=cache_config.get("max_size"),
+ cleanup_interval=cache_config.get("cleanup_interval", 300),
+ )
+
+ if cache_type == "redis":
+ return redis_cls(
+ url=cache_config.get("url"),
+ host=cache_config.get("host"),
+ port=cache_config.get("port"),
+ db=cache_config.get("db"),
+ password=cache_config.get("password"),
+ namespace=cache_config.get("namespace"),
+ max_connections=cache_config.get("max_connections", 50),
+ socket_timeout=cache_config.get("socket_timeout", 5.0),
+ health_check_interval=cache_config.get("health_check_interval", 30),
+ )
+
+ raise ValueError(f"Unsupported cache type: {cache_type}")
+
+ raise ValueError(f"Invalid cache_config type: {type(cache_config)}")
diff --git a/src/mcpstore/core/store/context_factory.py b/src/mcpstore/core/store/context_factory.py
new file mode 100644
index 00000000..9c5d38f1
--- /dev/null
+++ b/src/mcpstore/core/store/context_factory.py
@@ -0,0 +1,96 @@
+"""
+Context factory module
+Responsible for handling MCPStore context creation and management functionality
+"""
+
+import logging
+from typing import Dict, List, Optional
+
+from mcpstore.core.context import MCPStoreContext
+from mcpstore.core.context.agent_proxy import AgentProxy
+from mcpstore.core.context.store_proxy import StoreProxy
+
+logger = logging.getLogger(__name__)
+
+
+class ContextFactoryMixin:
+ """Context factory Mixin"""
+
+ def _create_store_context(self) -> MCPStoreContext:
+ """Create store-level context"""
+ return MCPStoreContext(self)
+
+ def get_store_context(self) -> MCPStoreContext:
+ """Get store-level context"""
+ return self._store_context
+
+ def _create_agent_context(self, agent_id: str) -> MCPStoreContext:
+ """Create agent-level context"""
+ return MCPStoreContext(self, agent_id)
+
+ def for_store(self) -> StoreProxy:
+ """Get store-level object (proxy)"""
+ return self._store_context.for_store()
+
+ def find_cache(self):
+ """Get global cache proxy (store scope)."""
+ return self._store_context.find_cache()
+
+ def for_agent(self, agent_id: str) -> AgentProxy:
+ """
+ Get agent-level object (proxy) with unified caching.
+
+ Uses the centralized AgentProxy caching system to ensure that the same
+ agent_id always returns the same AgentProxy instance across all access
+ methods in the MCPStore.
+
+ Args:
+ agent_id: Unique identifier for the agent
+
+ Returns:
+ AgentProxy: Cached or newly created AgentProxy instance
+ """
+ # Create or reuse agent context (still cached for efficiency)
+ if agent_id not in self._context_cache:
+ self._context_cache[agent_id] = self._create_agent_context(agent_id)
+
+ agent_context = self._context_cache[agent_id]
+
+ # Use unified AgentProxy caching system
+ return self._get_or_create_agent_proxy(agent_context, agent_id)
+
+
+ # Delegation methods - maintain backward compatibility
+ async def add_service(self, service_names: List[str] = None, agent_id: Optional[str] = None, **kwargs) -> bool:
+ """
+ Delegate to Context layer add_service method
+ Maintain backward compatibility
+
+ Args:
+ service_names: List of service names (compatible with old API)
+ agent_id: Agent ID (optional)
+ **kwargs: Other parameters passed to Context layer
+
+ Returns:
+ bool: Whether operation succeeded
+ """
+ context = self.for_agent(agent_id) if agent_id else self.for_store()
+
+ # If service_names is provided, convert to new format
+ if service_names:
+ # Compatible with old API, convert service_names to config format
+ config = {"service_names": service_names}
+ await context.add_service_async(config, **kwargs)
+ else:
+ # New API, pass parameters directly
+ await context.add_service_async(**kwargs)
+
+ return True
+
+ def check_services(self, agent_id: Optional[str] = None) -> Dict[str, str]:
+ """
+ Delegate to Context layer check_services method
+ Compatible with old API
+ """
+ context = self.for_agent(agent_id) if agent_id else self.for_store()
+ return context.check_services()
diff --git a/src/mcpstore/core/store/data_space_manager.py b/src/mcpstore/core/store/data_space_manager.py
new file mode 100644
index 00000000..088716a7
--- /dev/null
+++ b/src/mcpstore/core/store/data_space_manager.py
@@ -0,0 +1,172 @@
+"""
+Data Space Management Module
+Handles data space related functionality for MCPStore
+"""
+
+import logging
+from typing import Optional, Dict, Any, List
+
+logger = logging.getLogger(__name__)
+
+
+from pathlib import Path
+import json
+
+class DataSpaceManagerMixin:
+ """Data Space Management Mixin"""
+
+ def get_data_space_info(self) -> Optional[Dict[str, Any]]:
+ """
+ Get data space information
+
+ Returns:
+ Dict: Data space information, returns None if data space is not used
+ """
+ if self._data_space_manager:
+ return self._data_space_manager.get_workspace_info()
+ return None
+
+ def get_workspace_dir(self) -> Optional[str]:
+ """
+ Get workspace directory path
+
+ Returns:
+ str: Workspace directory path, returns None if data space is not used
+ """
+ if self._data_space_manager:
+ return str(self._data_space_manager.workspace_dir)
+ return None
+
+ def is_using_data_space(self) -> bool:
+ """
+ Check if data space is being used
+
+ Returns:
+ bool: Whether data space is being used
+ """
+ return self._data_space_manager is not None
+
+ async def _add_service(self, service_names: List[str], agent_id: Optional[str]) -> bool:
+ """Internal method: batch add services, store level supports full registration, agent level supports specified service registration"""
+ # store level
+ if agent_id is None:
+ if not service_names:
+ # Full registration: use unified synchronization mechanism
+ if hasattr(self.orchestrator, 'sync_manager') and self.orchestrator.sync_manager:
+ sync_results = await self.orchestrator.sync_manager.sync_global_agent_store_from_mcp_json()
+ return bool(sync_results.get("added") or sync_results.get("updated"))
+ else:
+ logger.warning("Unified sync manager not available, skipping full registration")
+ return False
+ else:
+ # Read service configuration from cache and follow unified cache-first process
+ try:
+ mcp_config = {"mcpServers": {}}
+ cache_agent_id = self.client_manager.global_agent_store_id
+ missing = []
+ for name in service_names:
+ svc_cfg = await self.registry.get_service_config_from_cache_async(cache_agent_id, name)
+ if not svc_cfg:
+ missing.append(name)
+ else:
+ mcp_config["mcpServers"][name] = svc_cfg
+ if missing:
+ logger.error(f"The following services were not found in cache configuration: {missing}")
+ return False
+ await self.for_store().add_service_async(mcp_config)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to add service via cache: {e}")
+ return False
+ # agent级别
+ else:
+ if service_names:
+ try:
+ mcp_config = {"mcpServers": {}}
+ cache_agent_id = agent_id
+ missing = []
+ for name in service_names:
+ svc_cfg = await self.registry.get_service_config_from_cache_async(cache_agent_id, name)
+ if not svc_cfg:
+ missing.append(name)
+ else:
+ mcp_config["mcpServers"][name] = svc_cfg
+ if missing:
+ logger.error(f"Agent({agent_id}) the following services were not found in cache: {missing}")
+ return False
+ await self.for_agent(agent_id).add_service_async(mcp_config)
+ return True
+ except Exception as e:
+ logger.error(f"Agent failed to add service via cache: {e}")
+ return False
+ else:
+ logger.warning(f"Agent {agent_id} level does not support full registration")
+ return False
+
+ async def add_service(self, service_names: List[str], agent_id: Optional[str] = None) -> bool:
+ """异步版本的add_service方法"""
+ return await self._add_service(service_names, agent_id)
+
+
+class DataSpaceManager:
+ """最小实现:用于数据空间初始化与信息查询(单一数据源模式)"""
+
+ def __init__(self, mcp_json_path: str):
+ self.mcp_json_path = Path(mcp_json_path).resolve()
+ self.workspace_dir = self.mcp_json_path.parent
+ logger.info(f"DataSpaceManager initialized for workspace: {self.workspace_dir}")
+
+ def initialize_workspace(self) -> bool:
+ """确保工作目录存在,并保证 mcp.json 存在且格式基本正确"""
+ try:
+ # 创建目录
+ self.workspace_dir.mkdir(parents=True, exist_ok=True)
+
+ # 如果没有 mcp.json,创建基础结构
+ if not self.mcp_json_path.exists():
+ self.mcp_json_path.write_text(json.dumps({"mcpServers": {}}, indent=2, ensure_ascii=False), encoding="utf-8")
+ logger.info(f"Created new MCP JSON file: {self.mcp_json_path}")
+ else:
+ # 简单结构校验:必须是 dict 且包含 mcpServers 字段
+ try:
+ data = json.loads(self.mcp_json_path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict) or "mcpServers" not in data or not isinstance(data["mcpServers"], dict):
+ # 备份并重建
+ backup = self.mcp_json_path.with_suffix(self.mcp_json_path.suffix + ".bak")
+ backup.write_text(self.mcp_json_path.read_text(encoding="utf-8"), encoding="utf-8")
+ self.mcp_json_path.write_text(json.dumps({"mcpServers": {}}, indent=2, ensure_ascii=False), encoding="utf-8")
+ logger.warning(f"Invalid mcp.json structure fixed, backup saved: {backup}")
+ except Exception as e:
+ # 读取失败则直接重建
+ backup = self.mcp_json_path.with_suffix(self.mcp_json_path.suffix + ".bak")
+ try:
+ backup.write_text(self.mcp_json_path.read_text(encoding="utf-8"), encoding="utf-8")
+ except Exception:
+ pass
+ self.mcp_json_path.write_text(json.dumps({"mcpServers": {}}, indent=2, ensure_ascii=False), encoding="utf-8")
+ logger.warning(f"Recreated invalid mcp.json, reason: {e}")
+
+ return True
+ except Exception as e:
+ logger.error(f"Failed to initialize workspace: {e}")
+ return False
+
+ def get_workspace_info(self) -> Dict[str, Any]:
+ """返回工作区信息"""
+ return {
+ "workspace_dir": str(self.workspace_dir),
+ "mcp_json_path": str(self.mcp_json_path),
+ "mcp_json_exists": self.mcp_json_path.exists(),
+ }
+
+ def get_file_path(self, relative_path: str) -> Path:
+ """
+ 获取工作空间内文件的完整路径
+
+ Args:
+ relative_path: 相对于工作空间的路径
+
+ Returns:
+ Path: 完整的文件路径
+ """
+ return self.workspace_dir / relative_path
diff --git a/src/mcpstore/core/store/service_query.py b/src/mcpstore/core/store/service_query.py
new file mode 100644
index 00000000..6ef60d76
--- /dev/null
+++ b/src/mcpstore/core/store/service_query.py
@@ -0,0 +1,552 @@
+"""
+MCPStore Service Query Module
+服务查询相关功能实现,提供服务列表、详情查询、健康检查等核心功能
+支持 Store 和 Agent 两种上下文模式,实现严格的服务隔离和透明代理
+"""
+
+import logging
+from typing import Optional, List, Dict, Any
+
+from mcpstore.core.models.service import ServiceInfo, ServiceConnectionState, TransportType, ServiceInfoResponse
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceQueryMixin:
+ """服务查询混入类,提供服务列表、详情查询、健康检查等功能"""
+
+ def check_services(self, agent_id: Optional[str] = None) -> Dict[str, str]:
+ """兼容性API,委托给上下文执行健康检查"""
+ context = self.for_agent(agent_id) if agent_id else self.for_store()
+ return context.check_services()
+
+ def _infer_transport_type(self, service_config: Dict[str, Any]) -> TransportType:
+ """Infer transport type of service"""
+ if not service_config:
+ return TransportType.STREAMABLE_HTTP
+
+ # Prefer transport field first
+ transport = service_config.get("transport")
+ if transport:
+ try:
+ return TransportType(transport)
+ except ValueError:
+ pass
+
+ # Then check based on url
+ if service_config.get("url"):
+ return TransportType.STREAMABLE_HTTP
+
+ # Check based on command/args
+ cmd = (service_config.get("command") or "").lower()
+ args = " ".join(service_config.get("args", [])).lower()
+
+ # Check if it's a Node.js package
+ if "npx" in cmd or "node" in cmd or "npm" in cmd:
+ return TransportType.STDIO
+
+ # Check if it's a Python package
+ if "python" in cmd or "pip" in cmd or ".py" in args:
+ return TransportType.STDIO
+
+ return TransportType.STREAMABLE_HTTP
+
+ async def list_services(self, id: Optional[str] = None, agent_mode: bool = False) -> List[ServiceInfo]:
+ """
+ 纯缓存模式的服务列表获取
+
+ 新特点:
+ - 完全从缓存获取数据
+ - 包含完整的 Agent-Client 信息
+ - 高性能,无文件IO
+ """
+ services_info = []
+
+ # 1. Store模式:直接从缓存层获取所有服务
+ if not agent_mode and (not id or id == self.client_manager.global_agent_store_id):
+ agent_id = self.client_manager.global_agent_store_id
+
+ # 使用 _cache_layer_manager(CacheLayerManager)获取所有服务实体
+ # 不再使用 _cache_layer,因为它在 Redis 模式下是 RedisStore,没有 get_all_entities_async 方法
+ try:
+ services = await self.registry._cache_layer_manager.get_all_entities_async("services")
+ logger.debug(f"[QUERY] Retrieved service data: {services}")
+ except Exception as e:
+ logger.error(f"Failed to get services from cache: {e}")
+ raise
+
+ if not services:
+ # 缓存为空,可能需要初始化
+ logger.info("Cache is empty, you may need to add services first")
+ return []
+
+ for service_global_name, service_data in services.items():
+ # 处理 ManagedEntry 对象
+ if hasattr(service_data, 'value'):
+ actual_data = service_data.value
+ logger.debug(f"[QUERY] Extracting ManagedEntry.value: {actual_data}")
+ else:
+ actual_data = service_data
+ logger.debug(f"[QUERY] Using data directly: {actual_data}")
+
+ # 获取服务名称
+ service_name = actual_data.get('service_original_name', service_global_name)
+ logger.debug(f"[QUERY] Service name: {service_name}")
+
+ # 从缓存获取完整信息 - 在异步上下文中调用异步版本
+ logger.info(f"[QUERY] Getting complete service info: service_global_name={service_global_name}, service_name={service_name}")
+ complete_info = await self.registry.get_complete_service_info_async(agent_id, service_global_name)
+
+ logger.info(f"[QUERY] Got complete info: complete_info={complete_info}")
+
+ # 安全检查,确保 complete_info 不为 None
+ if complete_info is None:
+ logger.error(f"[QUERY] Service {service_global_name} complete info is NULL, using default values")
+ complete_info = {
+ "name": service_name,
+ "state": "disconnected",
+ "config": {},
+ "tool_count": 0,
+ "tools": []
+ }
+
+ # 防御性编程:确保 config 不为 None
+ if complete_info.get("config") is None:
+ complete_info["config"] = {}
+
+ # 从 pykv 缓存层直接获取服务状态(唯一真相数据源)
+ # 使用 cache/state_manager.py 的 get_service_status 方法
+ cache_state_manager = getattr(self.registry, '_cache_state_manager', None)
+ if cache_state_manager is not None:
+ status_data = await cache_state_manager.get_service_status(service_global_name)
+ if status_data is not None:
+ if hasattr(status_data, 'health_status'):
+ state = status_data.health_status
+ elif isinstance(status_data, dict):
+ state = status_data.get('health_status', 'disconnected')
+ else:
+ state = str(status_data)
+ logger.debug(f"[QUERY] Getting state from pykv: {service_global_name} -> {state}")
+ else:
+ state = complete_info.get("state") or "disconnected"
+ logger.debug(f"[QUERY] No state in pykv, using default value: {state}")
+ else:
+ state = complete_info.get("state") or "disconnected"
+ logger.warning(f"[QUERY] Cache layer state manager unavailable, using state from complete_info: {state}")
+
+ # 确保状态是ServiceConnectionState枚举
+ if isinstance(state, str):
+ try:
+ state = ServiceConnectionState(state)
+ except ValueError:
+ state = ServiceConnectionState.DISCONNECTED
+
+ # 读取时按需触发异步健康检查(非阻塞)
+ try:
+ health_monitor = getattr(self, "container", None).health_monitor if getattr(self, "container", None) else None
+ if health_monitor:
+ await health_monitor.maybe_schedule_health_check(agent_id, service_global_name, current_state=state)
+ except Exception as e:
+ logger.debug(f"[QUERY] schedule health check failed: {e}")
+
+ service_info = ServiceInfo(
+ url=complete_info.get("config", {}).get("url", ""),
+ name=service_name,
+ transport_type=self._infer_transport_type(complete_info.get("config", {})),
+ status=state,
+ tool_count=complete_info.get("tool_count", 0),
+ keep_alive=complete_info.get("config", {}).get("keep_alive", False),
+ working_dir=complete_info.get("config", {}).get("working_dir"),
+ env=complete_info.get("config", {}).get("env"),
+ last_heartbeat=complete_info.get("last_heartbeat"),
+ command=complete_info.get("config", {}).get("command"),
+ args=complete_info.get("config", {}).get("args"),
+ package_name=complete_info.get("config", {}).get("package_name"),
+ state_metadata=complete_info.get("state_metadata"),
+ last_state_change=complete_info.get("state_entered_time"),
+ client_id=complete_info.get("client_id"), # 新增:Client ID 信息
+ config=complete_info.get("config", {}) # [REFACTOR] 添加完整的config字段
+ )
+ services_info.append(service_info)
+
+ # 2. Agent模式:作为“视图”,从 Store 命名空间派生服务列表
+ elif agent_mode and id:
+ try:
+ agent_id = id
+ global_agent_id = self.client_manager.global_agent_store_id
+
+ # 通过映射获取该 Agent 的全局服务名集合
+ global_service_names = self.registry.get_agent_services(agent_id)
+ if not global_service_names:
+ logger.debug(f"[STORE.LIST_SERVICES] Agent {agent_id} has no mapped global services, returning empty list")
+ return services_info
+
+ for global_name in global_service_names:
+ # 解析出本地名(显示用)并校验归属
+ parsed = self.registry.get_agent_service_from_global_name(global_name)
+ if not parsed:
+ continue
+ mapped_agent, local_name = parsed
+ if mapped_agent != agent_id:
+ continue
+
+ # 从全局命名空间读取该服务的完整信息
+ complete_info = self.registry.get_complete_service_info(global_agent_id, global_name)
+ if not complete_info:
+ logger.debug(f"[STORE.LIST_SERVICES] Service not found in global cache: {global_name}")
+ continue
+
+ # 状态枚举转换
+ state = complete_info.get("state", "disconnected")
+ if isinstance(state, str):
+ try:
+ state = ServiceConnectionState(state)
+ except ValueError:
+ state = ServiceConnectionState.DISCONNECTED
+
+ # 读取时按需触发异步健康检查(非阻塞)
+ try:
+ health_monitor = getattr(self, "container", None).health_monitor if getattr(self, "container", None) else None
+ if health_monitor:
+ await health_monitor.maybe_schedule_health_check(agent_id, global_name, current_state=state)
+ except Exception as e:
+ logger.debug(f"[STORE.LIST_SERVICES] schedule health check failed: {e}")
+
+ # 构建以本地名展示的 ServiceInfo(数据来源于全局)
+ cfg = complete_info.get("config", {})
+ service_info = ServiceInfo(
+ url=cfg.get("url", ""),
+ name=local_name or global_name,
+ transport_type=self._infer_transport_type(cfg),
+ status=state,
+ tool_count=complete_info.get("tool_count", 0),
+ keep_alive=cfg.get("keep_alive", False),
+ working_dir=cfg.get("working_dir"),
+ env=cfg.get("env"),
+ last_heartbeat=complete_info.get("last_heartbeat"),
+ command=cfg.get("command"),
+ args=cfg.get("args"),
+ package_name=cfg.get("package_name"),
+ state_metadata=complete_info.get("state_metadata"),
+ last_state_change=complete_info.get("state_entered_time"),
+ # 透明代理:client_id 使用全局命名空间的client
+ client_id=complete_info.get("client_id"),
+ config=cfg
+ )
+ services_info.append(service_info)
+ except Exception as e:
+ logger.error(f"[STORE.LIST_SERVICES] Agent view derivation failed: {e}")
+ return services_info
+
+ return services_info
+
+ async def get_service_info(self, name: str, agent_id: Optional[str] = None) -> ServiceInfoResponse:
+ """
+ 获取服务详细信息(严格按上下文隔离):
+ - 未传 agent_id:仅在 global_agent_store 下所有 client_id 中查找服务
+ - 传 agent_id:仅在该 agent_id 下所有 client_id 中查找服务
+
+ 优先级:按client_id顺序返回第一个匹配的服务
+ """
+ from mcpstore.core.store.client_manager import ClientManager
+ client_manager: ClientManager = self.client_manager
+
+ # 严格按上下文获取要查找的服务列表
+ # [pykv 唯一真相源] 从关系层获取
+ relation_manager = self.registry._relation_manager
+ if not agent_id:
+ # Store上下文:只查找global_agent_store下的服务
+ effective_agent_id = self.client_manager.global_agent_store_id
+ context_type = "store"
+ else:
+ # Agent上下文:只查找指定agent下的服务
+ effective_agent_id = agent_id
+ context_type = f"agent({agent_id})"
+
+ agent_services = await relation_manager.get_agent_services(effective_agent_id)
+ client_ids = list(set(svc.get("client_id") for svc in agent_services if svc.get("client_id")))
+
+ if not client_ids:
+ return ServiceInfoResponse(
+ success=False,
+ message=f"No client_ids found for {context_type} context",
+ service=None,
+ tools=[],
+ connected=False
+ )
+
+ # 按client_id顺序查找服务
+ # 修复:服务存储在agent_id级别,而不是client_id级别
+ agent_id_for_query = self.client_manager.global_agent_store_id if not agent_id else agent_id
+
+ # === 健壮名称解析:支持在 Agent 上下文传入“本地名”或“全局名” ===
+ query_names: List[str] = [name]
+ from mcpstore.core.context.agent_service_mapper import AgentServiceMapper
+ try:
+ if agent_id:
+ # 如果传入的是全局名(包含 _byagent_),尝试解析回本地名,确保在 agent 命名空间可匹配
+ if AgentServiceMapper.is_any_agent_service(name):
+ parsed = self.registry.get_agent_service_from_global_name(name)
+ if parsed:
+ parsed_agent_id, local_name = parsed
+ # 仅当全局名确实属于当前 agent 时才使用解析出的本地名
+ if parsed_agent_id == agent_id and local_name:
+ query_names.append(local_name)
+ else:
+ # 传入可能是本地名,同步构造对应全局名,方便后续 cross-namespace 校验
+ mapper = AgentServiceMapper(agent_id)
+ query_names.append(mapper.to_global_name(name))
+ except Exception:
+ pass
+
+ # [pykv 唯一真相源] 在 async 上下文中必须使用 async 方法从 pykv 读取
+ service_names = await self.registry._service_state_service.get_all_service_names_async(agent_id_for_query)
+
+ # 遍历候选名称,找到第一个匹配的(在 agent 命名空间)
+ match_name = next((qn for qn in query_names if qn in service_names), None)
+ if match_name:
+ # 推导本地名/全局名
+ local_name = name
+ global_name = None
+ if agent_id:
+ # 优先从映射表获取全局名(使用异步版本,避免 AOB 事件循环冲突)
+ global_name = await self.registry.get_global_name_from_agent_service_async(agent_id, local_name)
+ # 如果 match_name 已经是全局名,则直接使用
+ if not global_name and AgentServiceMapper.is_any_agent_service(match_name):
+ global_name = match_name
+ # 如果仍然没有,构造一个(不会影响存在性,仅用于读取配置)
+ if not global_name:
+ mapper = AgentServiceMapper(agent_id)
+ global_name = mapper.to_global_name(local_name)
+ else:
+ # store 模式下,名称即全局名
+ global_name = match_name
+
+ # 确定用于读取配置/生命周期/工具的命名空间与名称
+ config_key = global_name # 单一数据源:mcp.json 使用全局名
+ lifecycle_agent = self.client_manager.global_agent_store_id if agent_id else agent_id_for_query
+ lifecycle_name = global_name if agent_id else match_name
+ tools_agent = self.client_manager.global_agent_store_id if agent_id else agent_id_for_query
+ tools_service = global_name if agent_id else match_name
+
+ # 找到服务,需要确定它属于哪个client_id(保持 agent 视角)
+ # [pykv 唯一真相源] 使用异步方法从 pykv 读取
+ service_client_id = await self.registry._agent_client_service.get_service_client_id_async(agent_id_for_query, match_name)
+ if service_client_id and service_client_id in client_ids:
+ # 找到服务,获取详细信息
+ # 从 mcp.json 读取(使用全局名)
+ config = self.config.get_service_config(config_key) or {}
+
+ # [pykv 唯一真相源] 使用异步方法获取生命周期状态
+ service_state = await self.registry._service_state_service.get_service_state_async(lifecycle_agent, lifecycle_name)
+
+ # 获取工具信息(优先全局命名空间)
+ tool_names = self.registry.get_tools_for_service(tools_agent, tools_service)
+ tools_info = []
+ for tool_name in tool_names:
+ tool_info = self.registry.get_tool_info(tools_agent, tool_name)
+ if tool_info:
+ tools_info.append(tool_info)
+ tool_count = len(tools_info)
+
+ # 获取连接状态
+ connected = service_state in [ServiceConnectionState.HEALTHY, ServiceConnectionState.WARNING]
+
+ # [pykv 唯一真相源] 从 pykv 异步获取元数据
+ service_metadata = await self.registry._service_state_service.get_service_metadata_async(lifecycle_agent, lifecycle_name)
+
+ # 构建ServiceInfo(Agent 视图下 name 使用本地名展示)
+ service_info = ServiceInfo(
+ url=config.get("url", ""),
+ name=local_name if agent_id else match_name,
+ transport_type=self._infer_transport_type(config),
+ status=service_state,
+ tool_count=tool_count,
+ keep_alive=config.get("keep_alive", False),
+ working_dir=config.get("working_dir"),
+ env=config.get("env"),
+ last_heartbeat=service_metadata.last_ping_time if service_metadata else None,
+ command=config.get("command"),
+ args=config.get("args"),
+ package_name=config.get("package_name"),
+ state_metadata=service_metadata,
+ last_state_change=service_metadata.state_entered_time if service_metadata else None,
+ client_id=service_client_id,
+ config=config
+ )
+
+ return ServiceInfoResponse(
+ success=True,
+ message=f"Service found in {context_type} context (client_id: {service_client_id})",
+ service=service_info,
+ tools=tools_info,
+ connected=connected
+ )
+
+ # 未找到服务
+ return ServiceInfoResponse(
+ success=False,
+ message=f"Service '{name}' not found in {context_type} context (searched {len(client_ids)} clients)",
+ service=None,
+ tools=[],
+ connected=False
+ )
+
+ async def get_health_status(self, id: Optional[str] = None, agent_mode: bool = False) -> Dict[str, Any]:
+ # NOTE:
+ # 统一采用“按 Agent 命名空间存储服务”的约定:
+ # - store 视角:使用 global_agent_store 作为命名空间
+ # - agent 视角:使用指定 agent_id 作为命名空间
+ # client_id 仅用于标注归属与过滤,不作为生命周期与配置的读写命名空间
+ """
+ 获取服务健康状态:
+ - store未传id 或 id==global_agent_store:聚合 global_agent_store 下所有 client_id 的服务健康状态
+ - store传普通 client_id:只查该 client_id 下的服务健康状态
+ - agent级别:聚合 agent_id 下所有 client_id 的服务健康状态;如果 id 不是 agent_id,尝试作为 client_id 查
+ """
+ from mcpstore.core.store.client_manager import ClientManager
+ client_manager: ClientManager = self.client_manager
+ services = []
+ # 1. store未传id 或 id==global_agent_store,聚合 global_agent_store 下所有 client_id 的服务健康状态
+ if not agent_mode and (not id or id == self.client_manager.global_agent_store_id):
+ agent_ns = self.client_manager.global_agent_store_id
+ # [pykv 唯一真相源] 在 async 上下文中必须使用 async 方法从 pykv 读取
+ # 修复:将同步调用改为异步调用,避免在 FastAPI 事件循环中触发 AOB 冲突
+ service_names = await self.registry._service_state_service.get_all_service_names_async(agent_ns)
+ for name in service_names:
+ config = self.config.get_service_config(name) or {}
+ # 生命周期与元数据:按 Agent 命名空间读取(使用异步版本)
+ service_state = await self.registry._service_state_service.get_service_state_async(agent_ns, name)
+ state_metadata = await self.registry._service_state_service.get_service_metadata_async(agent_ns, name)
+ # 标注该服务当前映射到哪个 client_id(使用异步版本)
+ client_id = await self.registry._agent_client_service.get_service_client_id_async(agent_ns, name)
+
+ service_status = {
+ "name": name,
+ "url": config.get("url", ""),
+ "transport_type": config.get("transport", ""),
+ "status": service_state.value if hasattr(service_state, "value") else str(service_state),
+ "command": config.get("command"),
+ "args": config.get("args"),
+ "package_name": config.get("package_name"),
+ "client_id": client_id,
+ # 生命周期元数据
+ "response_time": getattr(state_metadata, "response_time", None) if state_metadata else None,
+ "consecutive_failures": getattr(state_metadata, "consecutive_failures", 0) if state_metadata else 0,
+ "last_state_change": (state_metadata.state_entered_time.isoformat() if state_metadata and state_metadata.state_entered_time else None)
+ }
+ services.append(service_status)
+ return {
+ "orchestrator_status": "running",
+ "active_services": len(services),
+ "services": services
+ }
+ # 2. store传普通 client_id,只查该 client_id 下的服务健康状态
+ if not agent_mode and id:
+ if id == self.client_manager.global_agent_store_id:
+ return {
+ "orchestrator_status": "running",
+ "active_services": 0,
+ "services": []
+ }
+ # 仅返回当前 client_id 映射到的服务(仍按 Agent 命名空间读状态)
+ # [pykv 唯一真相源] 使用异步方法从 pykv 读取
+ agent_ns = self.client_manager.global_agent_store_id
+ all_names = await self.registry._service_state_service.get_all_service_names_async(agent_ns)
+ for name in all_names:
+ mapped = await self.registry._agent_client_service.get_service_client_id_async(agent_ns, name)
+ if mapped != id:
+ continue
+ config = self.config.get_service_config(name) or {}
+ service_state = await self.registry._service_state_service.get_service_state_async(agent_ns, name)
+ state_metadata = await self.registry._service_state_service.get_service_metadata_async(agent_ns, name)
+ service_status = {
+ "name": name,
+ "url": config.get("url", ""),
+ "transport_type": config.get("transport", ""),
+ "status": service_state.value if hasattr(service_state, "value") else str(service_state),
+ "command": config.get("command"),
+ "args": config.get("args"),
+ "package_name": config.get("package_name"),
+ "client_id": mapped,
+ "response_time": getattr(state_metadata, "response_time", None) if state_metadata else None,
+ "consecutive_failures": getattr(state_metadata, "consecutive_failures", 0) if state_metadata else 0,
+ "last_state_change": (state_metadata.state_entered_time.isoformat() if state_metadata and state_metadata.state_entered_time else None)
+ }
+ services.append(service_status)
+ return {
+ "orchestrator_status": "running",
+ "active_services": len(services),
+ "services": services
+ }
+ # 3. agent级别,聚合 agent_id 下所有 client_id 的服务健康状态;如果 id 不是 agent_id,尝试作为 client_id 查
+ if agent_mode and id:
+ # [pykv 唯一真相源] 从关系层获取
+ agent_services_for_id = await self.registry._relation_manager.get_agent_services(id)
+ client_ids = list(set(svc.get("client_id") for svc in agent_services_for_id if svc.get("client_id")))
+ if client_ids:
+ agent_ns = id
+ # [pykv 唯一真相源] 使用异步方法从 pykv 读取
+ names = await self.registry._service_state_service.get_all_service_names_async(agent_ns)
+ for name in names:
+ config = self.config.get_service_config(name) or {}
+ service_state = await self.registry._service_state_service.get_service_state_async(agent_ns, name)
+ state_metadata = await self.registry._service_state_service.get_service_metadata_async(agent_ns, name)
+ mapped_client = await self.registry._agent_client_service.get_service_client_id_async(agent_ns, name)
+ if mapped_client not in (client_ids or []):
+ continue
+ service_status = {
+ "name": name,
+ "url": config.get("url", ""),
+ "transport_type": config.get("transport", ""),
+ "status": service_state.value if hasattr(service_state, "value") else str(service_state),
+ "command": config.get("command"),
+ "args": config.get("args"),
+ "package_name": config.get("package_name"),
+ "client_id": mapped_client,
+ "response_time": getattr(state_metadata, "response_time", None) if state_metadata else None,
+ "consecutive_failures": getattr(state_metadata, "consecutive_failures", 0) if state_metadata else 0,
+ "last_state_change": (state_metadata.state_entered_time.isoformat() if state_metadata and state_metadata.state_entered_time else None)
+ }
+ services.append(service_status)
+ return {
+ "orchestrator_status": "running",
+ "active_services": len(services),
+ "services": services
+ }
+ else:
+ # id 不是 agent_id,则视为 client_id:过滤 agent 命名空间下映射到该 client 的服务
+ # [pykv 唯一真相源] 使用异步方法从 pykv 读取
+ agent_ns = self.client_manager.global_agent_store_id
+ names = await self.registry._service_state_service.get_all_service_names_async(agent_ns)
+ for name in names:
+ mapped_client = await self.registry._agent_client_service.get_service_client_id_async(agent_ns, name)
+ if mapped_client != id:
+ continue
+ config = self.config.get_service_config(name) or {}
+ service_state = await self.registry._service_state_service.get_service_state_async(agent_ns, name)
+ state_metadata = await self.registry._service_state_service.get_service_metadata_async(agent_ns, name)
+ service_status = {
+ "name": name,
+ "url": config.get("url", ""),
+ "transport_type": config.get("transport", ""),
+ "status": service_state.value if hasattr(service_state, "value") else str(service_state),
+ "command": config.get("command"),
+ "args": config.get("args"),
+ "package_name": config.get("package_name"),
+ "client_id": mapped_client,
+ "response_time": getattr(state_metadata, "response_time", None) if state_metadata else None,
+ "consecutive_failures": getattr(state_metadata, "consecutive_failures", 0) if state_metadata else 0,
+ "last_state_change": (state_metadata.state_entered_time.isoformat() if state_metadata and state_metadata.state_entered_time else None)
+ }
+ services.append(service_status)
+ return {
+ "orchestrator_status": "running",
+ "active_services": len(services),
+ "services": services
+ }
+ return {
+ "orchestrator_status": "running",
+ "active_services": 0,
+ "services": []
+ }
diff --git a/src/mcpstore/core/store/setup_manager.py b/src/mcpstore/core/store/setup_manager.py
new file mode 100644
index 00000000..1d769778
--- /dev/null
+++ b/src/mcpstore/core/store/setup_manager.py
@@ -0,0 +1,580 @@
+"""
+Setup Manager module (latest: single path)
+Handles unified initialization logic for MCPStore
+
+This module provides the core setup_store() method that initializes MCPStore
+with modern cache configuration (RedisConfig/MemoryConfig).
+"""
+
+import asyncio
+import logging
+import time
+from copy import deepcopy
+from typing import Optional, Dict, Any, Union
+
+from mcpstore.config.cache_config import DataSourceStrategy
+from mcpstore.config.toml_config import init_config
+
+logger = logging.getLogger(__name__)
+
+# Default namespace constant
+DEFAULT_NAMESPACE = "mcpstore"
+
+
+class StoreSetupManager:
+ """Setup Manager - Keep only single setup_store interface"""
+
+ @staticmethod
+ def setup_store(
+ mcpjson_path: str | None = None,
+ debug: bool | str = False,
+ cache: Optional[Union["MemoryConfig", "RedisConfig"]] = None,
+ static_config: Optional[Dict[str, Any]] = None,
+ cache_mode: str = "auto",
+ only_db: bool = False,
+ ):
+ """
+ Unified MCPStore initialization (synchronous entry point)
+
+ Args:
+ ... (keep consistent with async version)
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ pass
+ else:
+ raise RuntimeError("Detected running event loop: please use setup_store_async() interface.")
+
+ return asyncio.run(
+ StoreSetupManager._setup_store_internal(
+ mcpjson_path=mcpjson_path,
+ debug=debug,
+ cache=cache,
+ static_config=static_config,
+ cache_mode=cache_mode,
+ only_db=only_db,
+ )
+ )
+
+ @staticmethod
+ async def setup_store_async(
+ mcpjson_path: str | None = None,
+ debug: bool | str = False,
+ cache: Optional[Union["MemoryConfig", "RedisConfig"]] = None,
+ static_config: Optional[Dict[str, Any]] = None,
+ cache_mode: str = "auto",
+ only_db: bool = False,
+ ):
+ """
+ Unified MCPStore initialization (async entry point)
+ """
+ return await StoreSetupManager._setup_store_internal(
+ mcpjson_path=mcpjson_path,
+ debug=debug,
+ cache=cache,
+ static_config=static_config,
+ cache_mode=cache_mode,
+ only_db=only_db,
+ )
+
+ @staticmethod
+ async def _setup_store_internal(
+ mcpjson_path: str | None,
+ debug: bool | str,
+ cache: Optional[Union["MemoryConfig", "RedisConfig"]],
+ static_config: Optional[Dict[str, Any]],
+ cache_mode: str,
+ only_db: bool,
+ ):
+ """
+ Unified MCPStore initialization (shared logic)
+
+ Args:
+ mcpjson_path: mcp.json file path; None uses default (~/.mcpstore/mcp.json)
+ debug: False=OFF (completely silent); True=DEBUG; string=corresponding level
+ cache: Cache configuration object (MemoryConfig or RedisConfig), default None (use MemoryConfig)
+ static_config: Static configuration injection (monitoring/network/features/local_service)
+ cache_mode: Cache working mode ("auto" | "local" | "shared")
+ only_db: Use only pykv (only_db), do not read mcp.json
+
+ Returns:
+ MCPStore: Initialized MCPStore instance
+ """
+
+ # 1) Logging configuration
+ from mcpstore.config.config import LoggingConfig
+ LoggingConfig.setup_logging(debug=debug)
+
+ # 1.5) Initialize TOML-based global configuration (config.toml + MCPStoreConfig)
+ try:
+ await init_config()
+ except Exception as e:
+ logger.warning(f"Failed to initialize TOML configuration system, continuing with defaults: {e}")
+
+ # 2) Data space & configuration
+ from mcpstore.config.json_config import MCPConfig
+ from mcpstore.config.path_utils import get_user_default_mcp_path
+ from mcpstore.core.store.data_space_manager import DataSpaceManager
+
+ # only_db 模式:完全忽略本地 mcp.json,不创建 DataSpace/workspace
+ resolved_mcp_path = None
+ dsm = None
+ config = None
+ workspace_dir = None
+
+ if not only_db:
+ resolved_mcp_path = mcpjson_path or str(get_user_default_mcp_path())
+ dsm = DataSpaceManager(resolved_mcp_path)
+ if not dsm.initialize_workspace():
+ raise RuntimeError(f"Failed to initialize workspace for: {resolved_mcp_path}")
+ config = MCPConfig(json_path=resolved_mcp_path)
+ workspace_dir = str(dsm.workspace_dir)
+ base_cfg = config.load_config()
+ else:
+ # 纯 DB 模式:使用空配置,后续仅依赖 static_config 注入
+ if mcpjson_path is not None:
+ logger.warning("[SETUP] [WARN] only_db mode enabled, ignoring mcpjson_path parameter")
+ base_cfg = {}
+
+ stat = static_config or {}
+ # Map network.http_timeout_seconds -> timing.http_timeout_seconds (orchestrator depends on this field)
+ timing = {}
+ try:
+ http_timeout = stat.get("network", {}).get("http_timeout_seconds")
+ if http_timeout is not None:
+ timing["http_timeout_seconds"] = int(http_timeout)
+ except Exception:
+ pass
+ if timing:
+ base_cfg.setdefault("timing", {}).update(timing)
+ # Directly inject other configuration sections for use by subsequent modules
+ for key in ("monitoring", "network", "features", "local_service"):
+ if key in stat and isinstance(stat[key], dict):
+ base_cfg[key] = deepcopy(stat[key])
+
+ # If local service work directory is specified, set adapter work directory
+ if stat.get("local_service", {}).get("work_dir"):
+ from mcpstore.core.integration.local_service_adapter import set_local_service_manager_work_dir
+ set_local_service_manager_work_dir(stat["local_service"]["work_dir"])
+ elif workspace_dir:
+ from mcpstore.core.integration.local_service_adapter import set_local_service_manager_work_dir
+ set_local_service_manager_work_dir(workspace_dir)
+
+ # 4) Registry and cache backend
+ from mcpstore.core.registry.registry_factory import create_registry_from_kv_store
+ from mcpstore.config import (
+ MemoryConfig, RedisConfig, detect_strategy,
+ create_kv_store, get_namespace, start_health_check
+ )
+ from mcpstore.core.bridge import get_async_bridge
+ bridge = get_async_bridge()
+
+ # Handle cache configuration (default to MemoryConfig if not provided)
+ if cache is None:
+ cache = MemoryConfig()
+ logger.debug("Using default MemoryConfig for cache")
+
+ # Detect data source strategy based on cache type and explicit only_db toggle
+ strategy = detect_strategy(cache, resolved_mcp_path, only_db=only_db)
+ logger.info(f"Cache initialization: type={cache.cache_type.value}, strategy={strategy.value}")
+
+ # Set namespace: use default value "mcpstore" uniformly, user can override via RedisConfig.namespace
+ namespace = DEFAULT_NAMESPACE
+ if isinstance(cache, RedisConfig):
+ if cache.namespace is None:
+ cache.namespace = DEFAULT_NAMESPACE
+ logger.info(f"Using default namespace: {cache.namespace}")
+ else:
+ namespace = cache.namespace
+ logger.info(f"Using user-provided namespace: {cache.namespace}")
+
+ # Critical fix: For Redis backend, must create KV store in AOB background event loop
+ # This binds Redis connection to AOB event loop, all subsequent operations execute in same event loop
+ if isinstance(cache, RedisConfig):
+ # Create Redis KV store in AOB background event loop
+ async def _create_redis_kv_store():
+ from key_value.aio.stores.redis import RedisStore
+ namespace = get_namespace(cache)
+
+ if cache.client:
+ logger.debug(f"Creating RedisStore with user-provided client in AOB loop, namespace={namespace}")
+ return RedisStore(client=cache.client, default_collection=namespace)
+ elif cache.url:
+ logger.debug(f"Creating RedisStore with URL in AOB loop, namespace={namespace}")
+ return RedisStore(url=cache.url, default_collection=namespace)
+ else:
+ logger.debug(f"Creating RedisStore with parameters in AOB loop: host={cache.host}, port={cache.port or 6379}, db={cache.db or 0}, namespace={namespace}")
+ return RedisStore(
+ host=cache.host,
+ port=cache.port or 6379,
+ db=cache.db or 0,
+ password=cache.password,
+ default_collection=namespace
+ )
+
+ kv_store = await StoreSetupManager._run_via_bridge_async(
+ bridge,
+ _create_redis_kv_store(),
+ op_name="create_redis_kv_store",
+ )
+ logger.info(f"Created Redis KV store in AOB event loop: {type(kv_store).__name__}")
+ else:
+ # For MemoryStore, async entry also needs to avoid blocking current event loop
+ kv_store = await StoreSetupManager._create_memory_store(cache, create_kv_store)
+ logger.info(f"Created KV store: {type(kv_store).__name__}")
+
+ # Use factory pattern for zero delegation
+ # Pass unified namespace to registry
+ registry = create_registry_from_kv_store(kv_store, test_mode=False, namespace=namespace)
+
+ # [已移除] ConfigSyncManager 配置备份功能
+ # 原因: 所有一致性数据统一通过 add_service() 写入三层缓存架构
+ # 初始化时的配置备份会导致数据不一致,因此移除
+
+ # Track Redis client lifecycle (for cleanup)
+ _user_provided_redis_client = None
+ _system_created_redis_client = None
+ _health_check_task = None
+
+ # Start health check for Redis if configured
+ if isinstance(cache, RedisConfig):
+ # Get the Redis client from the store
+ try:
+ from key_value.aio.stores.redis import RedisStore
+ if isinstance(kv_store, RedisStore):
+ # Access the private _client attribute (py-key-value doesn't expose public client)
+ redis_client = kv_store._client
+
+ # Track whether client was user-provided or system-created
+ if cache.client is not None:
+ _user_provided_redis_client = redis_client
+ logger.info("Redis connection: using user-provided client (lifecycle managed by user)")
+ else:
+ _system_created_redis_client = redis_client
+ # Log connection details (mask password)
+ conn_info = []
+ if cache.url:
+ # Mask password in URL
+ masked_url = cache.url
+ if '@' in masked_url and '://' in masked_url:
+ parts = masked_url.split('://', 1)
+ if len(parts) == 2 and '@' in parts[1]:
+ auth_part = parts[1].split('@')[0]
+ if ':' in auth_part:
+ masked_url = masked_url.replace(auth_part.split(':')[1], '***')
+ conn_info.append(f"url={masked_url}")
+ else:
+ conn_info.append(f"host={cache.host or 'localhost'}")
+ conn_info.append(f"port={cache.port or 6379}")
+ conn_info.append(f"db={cache.db or 0}")
+
+ conn_info.append(f"namespace={cache.namespace}")
+ conn_info.append(f"max_connections={cache.max_connections}")
+ logger.info(f"Redis connection established: {', '.join(conn_info)}")
+
+ # Start health check
+ _health_check_task = start_health_check(cache, redis_client)
+ if _health_check_task:
+ logger.info(
+ f"Redis health check started: interval={cache.health_check_interval}s"
+ )
+ except Exception as e:
+ logger.error(f"Failed to initialize Redis connection: {e}", exc_info=True)
+
+ # Auto-detect cache mode based on strategy
+ if cache_mode == "auto":
+ # New semantics: only only_db is considered shared, all others are local
+ if strategy == DataSourceStrategy.ONLY_DB:
+ cache_mode = "shared"
+ else:
+ cache_mode = "local"
+ logger.debug(f"[SETUP] [MAP] Strategy {strategy.value} mapped to cache mode: {cache_mode}")
+
+ # 5) Orchestrator
+ from mcpstore.core.orchestrator import MCPOrchestrator
+
+ standalone_config_manager = None
+ if only_db:
+ # Provide minimal in-memory config manager to avoid MCPConfig falling back to file mode
+ class OnlyDBConfigManager:
+ def __init__(self):
+ self._services: Dict[str, Any] = {}
+
+ def get_mcp_config(self):
+ return {"mcpServers": {}}
+
+ def get_service_config(self, name):
+ return self._services.get(name)
+
+ def add_service_config(self, name, cfg):
+ self._services[name] = cfg
+
+ def get_all_service_configs(self):
+ return dict(self._services)
+
+ standalone_config_manager = OnlyDBConfigManager()
+
+ orchestrator = MCPOrchestrator(
+ base_cfg,
+ registry,
+ standalone_config_manager=standalone_config_manager,
+ mcp_config=config,
+ )
+
+
+
+ # 6) Instantiate Store (fixed composition class)
+ from mcpstore.core.store.composed_store import MCPStore as _MCPStore
+ store = _MCPStore(orchestrator, config)
+ # Always set data space manager since we always create it now
+ store._data_space_manager = dsm
+
+ # 7) Synchronously initialize orchestrator
+ # Critical: For Redis backend, must use AOB to ensure execution in same event loop
+ if isinstance(cache, RedisConfig):
+ await StoreSetupManager._run_via_bridge_async(
+ bridge,
+ orchestrator.setup(),
+ op_name="orchestrator.setup",
+ )
+ else:
+ await orchestrator.setup()
+
+ # 6.5) Pre-write core entities (store / agents)
+ async def _seed_core_entities():
+ try:
+ cache_layer = getattr(registry, "_cache_layer_manager", None)
+ if cache_layer is None:
+ logger.warning("[CACHE_SEED] cache_layer_manager not available; skip seeding core entities.")
+ return
+
+ now = int(time.time())
+ # seed agent: global_agent_store
+ try:
+ global_agent_id = getattr(store.client_manager, "global_agent_store_id", "global_agent_store")
+ except Exception:
+ global_agent_id = "global_agent_store"
+
+ agent_exists = await cache_layer.get_entity("agents", global_agent_id)
+ if agent_exists is None:
+ await cache_layer.put_entity(
+ "agents",
+ global_agent_id,
+ {
+ "agent_id": global_agent_id,
+ "created_time": now,
+ "last_active": now,
+ "is_global": True,
+ },
+ )
+
+ # seed store entity
+ store_key = "mcpstore"
+ store_payload = {
+ "store_id": store_key,
+ "namespace": namespace,
+ "cache_mode": cache_mode,
+ "mcpjson_path": resolved_mcp_path,
+ "workspace_dir": workspace_dir,
+ "created_time": now,
+ }
+ existing_store = await cache_layer.get_entity("store", store_key)
+ if existing_store is None:
+ await cache_layer.put_entity("store", store_key, store_payload)
+ except Exception as seed_error:
+ logger.warning(f"[CACHE_SEED] Failed to seed core entities: {seed_error}")
+
+ async def _backfill_clients_and_metadata():
+ try:
+ cache_layer = getattr(registry, "_cache_layer_manager", None)
+ if cache_layer is None:
+ logger.warning("[CACHE_SEED] cache_layer_manager not available; skip backfill.")
+ return
+ services = await cache_layer.get_all_entities_async("services")
+ relations = await cache_layer.get_all_relations_async("agent_services")
+
+ # Build service_global_name -> client_id mapping
+ client_by_service: dict[str, str] = {}
+ agent_by_service: dict[str, str] = {}
+ for agent_id, rel in relations.items():
+ for svc in rel.get("services", []) if isinstance(rel, dict) else []:
+ sg = svc.get("service_global_name")
+ cid = svc.get("client_id")
+ if sg:
+ client_by_service[sg] = cid
+ agent_by_service[sg] = agent_id
+
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ now_ts = int(time.time())
+
+ for sg, data in services.items():
+ if not isinstance(data, dict):
+ continue
+ agent_id = data.get("source_agent") or agent_by_service.get(sg) or "global_agent_store"
+ client_id = client_by_service.get(sg)
+ if not client_id:
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=data.get("service_original_name", sg),
+ service_config=data.get("config", {}),
+ global_agent_store_id="global_agent_store",
+ )
+
+ # Backfill clients entity
+ client_entity = await cache_layer.get_entity("clients", client_id)
+ if not isinstance(client_entity, dict):
+ client_entity = {
+ "client_id": client_id,
+ "agent_id": agent_id,
+ "services": [],
+ "created_time": now_ts,
+ }
+ services_list = client_entity.get("services") or []
+ if sg not in services_list:
+ services_list.append(sg)
+ client_entity.update({
+ "agent_id": agent_id,
+ "services": services_list,
+ "updated_time": now_ts,
+ })
+ await cache_layer.put_entity("clients", client_id, client_entity)
+
+ # Backfill service_metadata state
+ existing_meta = await cache_layer.get_state("service_metadata", sg)
+ if existing_meta is None:
+ metadata_state = {
+ "service_global_name": sg,
+ "agent_id": agent_id,
+ "created_time": now_ts,
+ "state_entered_time": now_ts,
+ "reconnect_attempts": 0,
+ "last_ping_time": None,
+ }
+ await cache_layer.put_state("service_metadata", sg, metadata_state)
+ except Exception as bf_error:
+ logger.warning(f"[CACHE_SEED] Backfill clients/metadata failed: {bf_error}")
+
+ if not only_db:
+ try:
+ if isinstance(cache, RedisConfig):
+ await StoreSetupManager._run_via_bridge_async(
+ bridge,
+ _seed_core_entities(),
+ op_name="cache.seed_core_entities",
+ )
+ else:
+ await _seed_core_entities()
+ except Exception as seed_outer_error:
+ logger.warning(f"[CACHE_SEED] Seeding core entities failed: {seed_outer_error}")
+
+ try:
+ if isinstance(cache, RedisConfig):
+ await StoreSetupManager._run_via_bridge_async(
+ bridge,
+ _backfill_clients_and_metadata(),
+ op_name="cache.backfill_clients_metadata",
+ )
+ else:
+ await _backfill_clients_and_metadata()
+ except Exception as bf_outer_error:
+ logger.warning(f"[CACHE_SEED] Backfill clients/metadata failed: {bf_outer_error}")
+
+ # [已移除] Phase 11: 配置同步 (sync_json_to_cache)
+ # 原因: 所有一致性数据统一通过 add_service() 写入三层缓存架构
+ # mcp.json 配置在服务连接时通过 add_service() 写入实体层/关系层/状态层
+
+ # 8) Optional: Preload cache
+ features = stat.get("features", {}) if isinstance(stat, dict) else {}
+ if features.get("preload_cache") and not only_db:
+ try:
+ if isinstance(cache, RedisConfig):
+ await StoreSetupManager._run_via_bridge_async(
+ bridge,
+ store.initialize_cache_from_files(),
+ op_name="store.initialize_cache_from_files",
+ )
+ else:
+ await store.initialize_cache_from_files()
+ except Exception as e:
+ if features.get("fail_on_cache_preload_error"):
+ raise
+ logger.warning(f"Cache preload failed (ignored): {e}")
+
+ # 9) Generate read-only configuration snapshot
+ try:
+ lvl = logging.getLogger().getEffectiveLevel()
+ if lvl <= logging.DEBUG:
+ level_name = "DEBUG"
+ elif lvl <= logging.INFO:
+ level_name = "INFO"
+ elif lvl <= logging.WARNING:
+ level_name = "WARNING"
+ elif lvl <= logging.ERROR:
+ level_name = "ERROR"
+ elif lvl <= logging.CRITICAL:
+ level_name = "CRITICAL"
+ else:
+ level_name = "OFF"
+ except Exception:
+ level_name = "OFF"
+
+ snapshot = {
+ "mcp_json": getattr(config, "json_path", None),
+ "debug_level": level_name,
+ "static_config": deepcopy(stat),
+ "cache_config": cache, # Store cache configuration object
+ }
+ try:
+ setattr(store, "_setup_snapshot", snapshot)
+ except Exception:
+ pass
+
+ # 10) Store Redis client lifecycle tracking for cleanup
+ try:
+ setattr(store, "_user_provided_redis_client", _user_provided_redis_client)
+ setattr(store, "_system_created_redis_client", _system_created_redis_client)
+ setattr(store, "_health_check_task", _health_check_task)
+ except Exception:
+ pass
+
+ return store
+
+ @staticmethod
+ async def _run_via_bridge_async(bridge, coro, *, op_name: str):
+ """
+ Execute coroutine that needs to be bound to AOB event loop in async context.
+ """
+ bridge_loop = getattr(bridge, "_loop", None)
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if running_loop is None:
+ return bridge.run(coro, op_name=op_name)
+
+ if bridge_loop and running_loop is bridge_loop:
+ return await coro
+
+ if bridge_loop is not None:
+ future = asyncio.run_coroutine_threadsafe(coro, bridge_loop)
+ return await asyncio.wrap_future(future)
+
+ # No bridge loop available, fallback to running sync bridge
+ return await asyncio.to_thread(bridge.run, coro, op_name=op_name)
+
+ @staticmethod
+ async def _create_memory_store(cache, create_fn):
+ """
+ Create MemoryStore in async context, avoid blocking current event loop.
+ """
+ try:
+ running_loop = asyncio.get_running_loop()
+ except RuntimeError:
+ running_loop = None
+
+ if running_loop is None:
+ return create_fn(cache)
+
+ return await asyncio.to_thread(create_fn, cache)
diff --git a/src/mcpstore/core/store/setup_mixin.py b/src/mcpstore/core/store/setup_mixin.py
new file mode 100644
index 00000000..f21da69a
--- /dev/null
+++ b/src/mcpstore/core/store/setup_mixin.py
@@ -0,0 +1,219 @@
+"""
+Setup Mixin Module
+Handles instance-level initialization methods for MCPStore
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class SetupMixin:
+ """Setup Mixin - contains instance-level initialization methods"""
+
+ async def initialize_cache_from_files(self):
+ """Initialize cache from files on startup"""
+ try:
+ logger.info(" [INIT_CACHE] Starting cache initialization from persistent files...")
+
+ # Single source mode: no longer initialize from ClientManager shard files
+ logger.info(" [INIT_CACHE] Single source mode: skipping basic data initialization from shard files")
+
+ # 2. Parse all services from mcp.json (including Agent services)
+ import os
+ config_path = getattr(self.config, 'config_path', None) or getattr(self.config, 'json_path', None)
+ if config_path and os.path.exists(config_path):
+ await self._initialize_services_from_mcp_config()
+
+ # 3. Mark cache as initialized
+ from datetime import datetime
+ self.registry.cache_sync_status["initialized"] = datetime.now()
+
+ logger.info(" Cache initialization completed")
+
+ except Exception as e:
+ logger.error(f" Cache initialization failed: {e}")
+ raise
+
+ def _find_existing_client_id_for_agent_service(self, agent_id: str, service_name: str) -> str:
+ """
+ Find if Agent service already has corresponding client_id
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 现有的client_id,如果不存在则返回None
+ """
+ try:
+ # 检查service_to_client映射(统一通过Registry API)
+ existing_client_id = self.registry._agent_client_service.get_service_client_id(agent_id, service_name)
+ if existing_client_id:
+ logger.debug(f"[INIT_MCP] [FOUND] Found existing Agent client_id: {service_name} -> {existing_client_id}")
+ return existing_client_id
+
+ # 检查agent_clients中是否有匹配的client_id(统一通过Registry API)
+ # 注意:这是同步方法,需要使用 asyncio.run
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ # 在异步上下文中,使用 run_coroutine_threadsafe
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(lambda: asyncio.run(self.registry.get_agent_clients_async(agent_id)))
+ client_ids = future.result(timeout=10.0)
+ except RuntimeError:
+ client_ids = asyncio.run(self.registry.get_agent_clients_async(agent_id))
+ for client_id in client_ids:
+ # 优先解析确定性ID
+ try:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ if ClientIDGenerator.is_deterministic_format(client_id):
+ parsed = ClientIDGenerator.parse_client_id(client_id)
+ if parsed.get("type") == "agent" \
+ and parsed.get("agent_id") == agent_id \
+ and parsed.get("service_name") == service_name:
+ logger.debug(f"[INIT_MCP] [FOUND] Found Agent client_id by parsing deterministic ID: {client_id}")
+ return client_id
+ except Exception:
+ pass
+ # 兼容旧格式:保留模式匹配
+ if f"_{agent_id}_{service_name}_" in client_id:
+ logger.debug(f"[INIT_MCP] [FOUND] Found Agent client_id by old format matching: {client_id}")
+ return client_id
+
+ return None
+
+ except Exception as e:
+ logger.error(f"Error finding existing Agent client_id for service {service_name}: {e}")
+ return None
+
+ def _find_existing_client_id_for_store_service(self, agent_id: str, service_name: str) -> str:
+ """
+ 查找Store服务是否已有对应的client_id
+
+ Args:
+ agent_id: Agent ID (通常是global_agent_store)
+ service_name: 服务名称
+
+ Returns:
+ 现有的client_id,如果不存在则返回None
+ """
+ try:
+ # 优先:通过 Registry 提供的映射API 获取
+ existing_client_id = self.registry._agent_client_service.get_service_client_id(agent_id, service_name)
+ if existing_client_id:
+ logger.debug(f"[INIT_MCP] [FOUND] Found existing Store client_id: {service_name} -> {existing_client_id}")
+ return existing_client_id
+
+ # 其次:检查 agent 的所有 client_ids(通过 Registry API)
+ # 注意:这是同步方法,需要使用 asyncio.run
+ import asyncio
+ try:
+ loop = asyncio.get_running_loop()
+ # 在异步上下文中,使用 run_coroutine_threadsafe
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(lambda: asyncio.run(self.registry.get_agent_clients_async(agent_id)))
+ client_ids = future.result(timeout=10.0)
+ except RuntimeError:
+ client_ids = asyncio.run(self.registry.get_agent_clients_async(agent_id))
+ for client_id in client_ids:
+ # 统一的确定性ID格式匹配:优先尝试解析
+ try:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+ if ClientIDGenerator.is_deterministic_format(client_id):
+ parsed = ClientIDGenerator.parse_client_id(client_id)
+ if parsed.get("type") == "store" and parsed.get("service_name") == service_name:
+ logger.debug(f"[INIT_MCP] [FOUND] Found Store client_id by parsing deterministic ID: {client_id}")
+ return client_id
+ except Exception:
+ pass
+ # 兼容旧格式:保留模式匹配
+ if f"client_store_{service_name}_" in client_id:
+ logger.debug(f"[INIT_MCP] [FOUND] Found Store client_id by old format matching: {client_id}")
+ return client_id
+
+ return None
+
+ except Exception as e:
+ logger.error(f"Error finding existing Store client_id for service {service_name}: {e}")
+ return None
+
+ async def _initialize_services_from_mcp_config(self):
+ """
+ 从 mcp.json 初始化服务,解析 Agent 服务并建立映射关系
+ """
+ try:
+ logger.info("[INIT_MCP] [START] Starting to parse services from mcp.json...")
+
+ # 读取 mcp.json 配置(优化:使用缓存)
+ mcp_config = self._unified_config.get_mcp_config()
+ mcp_servers = mcp_config.get("mcpServers", {})
+
+ if not mcp_servers:
+ logger.info("[INIT_MCP] [INFO] No service configuration in mcp.json")
+ return
+
+ logger.info(f"[INIT_MCP] [FOUND] Found {len(mcp_servers)} service configurations")
+
+ # 解析服务并建立映射关系
+ global_agent_store_id = self.client_manager.global_agent_store_id
+ for service_name, service_config in mcp_servers.items():
+ try:
+ # 通过名称后缀解析是否为 Agent 服务
+ from mcpstore.core.context.agent_service_mapper import AgentServiceMapper
+
+ if AgentServiceMapper.is_any_agent_service(service_name):
+ agent_id, local_name = AgentServiceMapper.parse_agent_service_name(service_name)
+ global_name = service_name
+ else:
+ agent_id = global_agent_store_id
+ local_name = service_name
+ global_name = service_name
+
+ logger.info(f"[INIT_MCP] [REPLAY] service={service_name} agent={agent_id} local={local_name}")
+
+ # 已存在则跳过
+ if await self.registry.has_service_async(agent_id, local_name):
+ logger.info(f"[INIT_MCP] [SKIP] Service already exists in cache: {agent_id}:{local_name}")
+ continue
+
+ # 发布 bootstrap 事件,构建缓存后后台连接
+ from mcpstore.core.events.service_events import ServiceBootstrapRequested
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=local_name,
+ service_config=service_config,
+ global_agent_store_id=global_agent_store_id
+ )
+
+ event_bus = getattr(getattr(self, "container", None), "_event_bus", None) or getattr(self, "event_bus", None) or getattr(getattr(self, "orchestrator", None), "event_bus", None)
+ if not event_bus:
+ raise RuntimeError("EventBus is not available during setup bootstrap")
+
+ bootstrap_event = ServiceBootstrapRequested(
+ agent_id=agent_id,
+ service_name=local_name,
+ service_config=service_config,
+ client_id=client_id,
+ global_name=global_name,
+ origin_agent_id=agent_id,
+ origin_local_name=local_name,
+ source="bootstrap_mcpjson"
+ )
+ await event_bus.publish(bootstrap_event, wait=False)
+ logger.info(f"[INIT_MCP] [OK] Published bootstrap event for service: {service_name} -> agent={agent_id}")
+
+ except Exception as e:
+ logger.error(f"[INIT_MCP] [ERROR] Failed to process service {service_name}: {e}")
+ continue
+
+ logger.info(f"[INIT_MCP] [COMPLETE] mcp.json parsing completed, processed {len(mcp_servers)} services")
+
+ except Exception as e:
+ logger.error(f"[INIT_MCP] [ERROR] Failed to initialize services from mcp.json: {e}")
+ raise
diff --git a/src/mcpstore/core/store/tool_operations.py b/src/mcpstore/core/store/tool_operations.py
new file mode 100644
index 00000000..28421dba
--- /dev/null
+++ b/src/mcpstore/core/store/tool_operations.py
@@ -0,0 +1,279 @@
+"""
+Tool Operations Module
+Handles MCPStore tool-related functionality
+"""
+
+import logging
+import time
+from typing import Optional, List, Dict, Any
+
+from mcpstore.core.models.common import ExecutionResponse
+from mcpstore.core.models.tool import ToolExecutionRequest, ToolInfo
+from mcpstore.core.models.tool_result import CallToolFailureResult
+
+logger = logging.getLogger(__name__)
+
+
+class ToolOperationsMixin:
+ """Tool operations Mixin"""
+
+ async def process_tool_request(self, request: ToolExecutionRequest) -> ExecutionResponse:
+ """
+ Process tool execution request (FastMCP standard)
+
+ Args:
+ request: Tool execution request
+
+ Returns:
+ ExecutionResponse: Tool execution response
+ """
+ start_time = time.time()
+
+ try:
+ # Validate request parameters
+ if not request.tool_name:
+ raise ValueError("Tool name cannot be empty")
+ if not request.service_name:
+ raise ValueError("Service name cannot be empty")
+
+ logger.debug(f"Processing tool request: {request.service_name}::{request.tool_name}")
+
+ # Check service lifecycle state
+ # For Agent transparent proxy, global services exist in global_agent_store
+ if request.agent_id and "_byagent_" in request.service_name:
+ # Agent transparent proxy: global services are in global_agent_store
+ state_check_agent_id = self.client_manager.global_agent_store_id
+ else:
+ # Store mode or normal Agent services
+ state_check_agent_id = request.agent_id or self.client_manager.global_agent_store_id
+
+ # Event-driven architecture: get state directly from registry (no longer through lifecycle_manager)
+ # 在 async 方法中必须使用 async 版本,避免 AOB 检测到已有事件循环抛出 RuntimeError
+ service_state = await self.registry._service_state_service.get_service_state_async(state_check_agent_id, request.service_name)
+
+ # 如果状态不健康,先记录但仍尝试执行,失败时再返回真实错误
+ from mcpstore.core.models.service import ServiceConnectionState
+ state_warn = service_state in [
+ ServiceConnectionState.RECONNECTING,
+ ServiceConnectionState.UNREACHABLE,
+ ServiceConnectionState.DISCONNECTING,
+ ServiceConnectionState.DISCONNECTED
+ ]
+ if state_warn:
+ logger.warning(
+ f"Service '{request.service_name}' is in state {service_state.value}, will still attempt execution"
+ )
+
+ # Execute tool (using FastMCP standard)
+ result = await self.orchestrator.execute_tool_fastmcp(
+ service_name=request.service_name,
+ tool_name=request.tool_name,
+ arguments=request.args,
+ agent_id=request.agent_id,
+ timeout=request.timeout,
+ progress_handler=request.progress_handler,
+ raise_on_error=request.raise_on_error,
+ session_id=getattr(request, 'session_id', None) # [NEW] Pass session ID if available
+ )
+
+ # [MONITORING] Record successful tool execution
+ try:
+ duration_ms = (time.time() - start_time) * 1000
+
+ # Get corresponding Context to record monitoring data
+ if request.agent_id:
+ context = self.for_agent(request.agent_id)
+ else:
+ context = self.for_store()
+
+ if getattr(context, "_monitoring", None):
+ context._monitoring.record_tool_execution_detailed(
+ tool_name=request.tool_name,
+ service_name=request.service_name,
+ params=request.args,
+ result=result,
+ error=None,
+ response_time=duration_ms
+ )
+ except Exception as monitor_error:
+ logger.warning(f"Failed to record tool execution: {monitor_error}")
+
+ return ExecutionResponse(
+ success=True,
+ result=result
+ )
+ except Exception as e:
+ # [MONITORING] Record failed tool execution
+ try:
+ duration_ms = (time.time() - start_time) * 1000
+
+ # Get corresponding Context to record monitoring data
+ if request.agent_id:
+ context = self.for_agent(request.agent_id)
+ else:
+ context = self.for_store()
+
+ if getattr(context, "_monitoring", None):
+ context._monitoring.record_tool_execution_detailed(
+ tool_name=request.tool_name,
+ service_name=request.service_name,
+ params=request.args,
+ result=None,
+ error=str(e),
+ response_time=duration_ms
+ )
+ except Exception as monitor_error:
+ logger.warning(f"Failed to record failed tool execution: {monitor_error}")
+
+ logger.error(f"Tool execution failed: {e}")
+ failure_result = CallToolFailureResult(str(e)).unwrap()
+ return ExecutionResponse(
+ success=False,
+ result=failure_result,
+ error=str(e)
+ )
+
+ async def call_tool(self, tool_name: str, args: Dict[str, Any]) -> Any:
+ """
+ Call tool (generic interface)
+
+ Args:
+ tool_name: Tool name, format: service_toolname
+ args: Tool parameters
+
+ Returns:
+ Any: Tool execution result
+ """
+ from mcpstore.core.models.tool import ToolExecutionRequest
+
+ # Build request
+ request = ToolExecutionRequest(
+ tool_name=tool_name,
+ args=args
+ )
+
+ # Process tool request
+ return await self.process_tool_request(request)
+
+ async def use_tool(self, tool_name: str, args: Dict[str, Any]) -> Any:
+ """
+ Use tool (generic interface) - backward compatibility alias
+
+ Note: This method is an alias for call_tool, maintaining backward compatibility.
+ It is recommended to use the call_tool method to remain consistent with FastMCP naming.
+ """
+ return await self.call_tool(tool_name, args)
+
+ async def _get_client_id_for_service_async(self, agent_id: str, service_name: str) -> str:
+ """
+ 获取服务对应的 client_id
+
+ [pykv 唯一真相源] 从 pykv 关系层读取
+ """
+ # 从 pykv 关系层获取 Agent 的服务列表
+ relation_manager = self.registry._relation_manager
+ agent_services = await relation_manager.get_agent_services(agent_id)
+
+ if not agent_services:
+ self.logger.warning(f"No services found in pykv for agent {agent_id}")
+ return ""
+
+ # 查找指定服务的 client_id
+ for svc in agent_services:
+ if svc.get("service_global_name") == service_name or svc.get("service_original_name") == service_name:
+ client_id = svc.get("client_id")
+ if client_id:
+ return client_id
+
+ # 如果没找到,返回第一个 client_id 作为默认值
+ first_client_id = agent_services[0].get("client_id") if agent_services else ""
+ if first_client_id:
+ self.logger.warning(f"Service {service_name} not found, using first client_id: {first_client_id}")
+ return first_client_id
+
+ async def list_tools(self, id: Optional[str] = None, agent_mode: bool = False) -> List[ToolInfo]:
+ """
+ 列出工具列表(直接从 pykv 读取,不使用快照)
+
+ 遵循 Functional Core, Imperative Shell 架构:
+ - pykv 是唯一真相数据源
+ - 不使用内存快照
+
+ Args:
+ id: Agent ID(可选)
+ agent_mode: 是否为 Agent 模式
+
+ Returns:
+ 工具列表
+ """
+ # 确定 agent_id
+ if agent_mode and id:
+ agent_id = id
+ else:
+ agent_id = self.client_manager.global_agent_store_id
+
+ # 获取管理器
+ relation_manager = self.registry._relation_manager
+ tool_entity_manager = self.registry._cache_tool_manager
+
+ # Step 1: 从关系层获取 Agent 的服务列表
+ agent_services = await relation_manager.get_agent_services(agent_id)
+
+ if not agent_services:
+ self.logger.debug(f"[STORE.LIST_TOOLS] no services for agent_id={agent_id}")
+ return []
+
+ # Step 2: 从关系层获取每个服务的工具列表
+ all_tool_global_names: List[str] = []
+
+ for svc in agent_services:
+ service_global_name = svc.get("service_global_name")
+ if not service_global_name:
+ continue
+
+ tool_relations = await relation_manager.get_service_tools(service_global_name)
+ for tr in tool_relations:
+ tool_global_name = tr.get("tool_global_name")
+ if tool_global_name:
+ all_tool_global_names.append(tool_global_name)
+
+ if not all_tool_global_names:
+ self.logger.debug(f"[STORE.LIST_TOOLS] no tools for agent_id={agent_id}")
+ return []
+
+ # Step 3: 从实体层批量获取工具实体
+ tool_entities = await tool_entity_manager.get_many_tools(all_tool_global_names)
+
+ # 构建 client_id 映射
+ client_id_map: Dict[str, str] = {}
+ for svc in agent_services:
+ service_global_name = svc.get("service_global_name")
+ client_id = svc.get("client_id")
+ if service_global_name and client_id:
+ client_id_map[service_global_name] = client_id
+
+ # Step 4: 构建工具列表
+ tools: List[ToolInfo] = []
+ for entity in tool_entities:
+ if entity is None:
+ continue
+
+ entity_dict = entity.to_dict() if hasattr(entity, 'to_dict') else entity
+ service_global_name = entity_dict.get("service_global_name", "")
+ service_original_name = entity_dict.get("service_original_name", "")
+ client_id = client_id_map.get(service_global_name)
+
+ tool_info = ToolInfo(
+ name=entity_dict.get("tool_global_name", ""),
+ tool_original_name=entity_dict.get("tool_original_name", ""),
+ description=entity_dict.get("description", ""),
+ service_name=service_original_name,
+ service_original_name=service_original_name,
+ service_global_name=service_global_name,
+ client_id=client_id,
+ inputSchema=entity_dict.get("input_schema", {})
+ )
+ tools.append(tool_info)
+
+ self.logger.debug(f"[STORE.LIST_TOOLS] agent_id={agent_id} tools_count={len(tools)}")
+ return tools
diff --git a/src/mcpstore/core/sync/__init__.py b/src/mcpstore/core/sync/__init__.py
new file mode 100644
index 00000000..97d3c9ef
--- /dev/null
+++ b/src/mcpstore/core/sync/__init__.py
@@ -0,0 +1,13 @@
+"""
+同步管理模块
+
+提供服务状态同步和配置同步功能
+"""
+
+from .bidirectional_sync_manager import BidirectionalSyncManager
+from .shared_client_state_sync import SharedClientStateSyncManager
+
+__all__ = [
+ 'SharedClientStateSyncManager',
+ 'BidirectionalSyncManager'
+]
diff --git a/src/mcpstore/core/sync/bidirectional_sync_manager.py b/src/mcpstore/core/sync/bidirectional_sync_manager.py
new file mode 100644
index 00000000..23359ffc
--- /dev/null
+++ b/src/mcpstore/core/sync/bidirectional_sync_manager.py
@@ -0,0 +1,256 @@
+"""
+双向同步管理器
+
+处理 Store ↔ Agent 之间的配置同步,确保:
+1. Agent 添加/修改/删除服务时,自动同步到 Store
+2. Store 修改 Agent 服务时,自动同步到对应的 Agent
+3. 保持 mcp.json 和两个 JSON 文件的一致性
+
+设计原则:
+1. 自动透明同步
+2. 原子性操作
+3. 错误容错机制
+4. 详细的同步日志
+"""
+
+import logging
+from typing import Dict, Any
+
+from mcpstore.core.context.agent_service_mapper import AgentServiceMapper
+
+logger = logging.getLogger(__name__)
+
+class BidirectionalSyncManager:
+ """Store ↔ Agent 双向配置同步管理器"""
+
+ def __init__(self, store):
+ """
+ 初始化双向同步管理器
+
+ Args:
+ store: MCPStore 实例
+ """
+ self.store = store
+ self._syncing_services: set = set() # 防止递归同步的标记
+
+ async def sync_agent_to_store(self, agent_id: str, local_name: str, new_config: Dict[str, Any], operation: str = "update"):
+ """
+ Agent 配置变更同步到 Store
+
+ Args:
+ agent_id: Agent ID
+ local_name: Agent 中的本地服务名
+ new_config: 新的服务配置
+ operation: 操作类型 ("add", "update", "delete")
+ """
+ sync_key = f"{agent_id}:{local_name}:{operation}"
+ if sync_key in self._syncing_services:
+ logger.debug(f" [BIDIRECTIONAL_SYNC] Skipping recursive sync: {sync_key}")
+ return
+
+ try:
+ self._syncing_services.add(sync_key)
+
+ # 使用异步版本,避免 AOB 事件循环冲突
+ global_name = await self.store.registry.get_global_name_from_agent_service_async(agent_id, local_name)
+ if not global_name:
+ logger.warning(f" [BIDIRECTIONAL_SYNC] No global mapping found for {agent_id}:{local_name}")
+ return
+
+ logger.info(f" [BIDIRECTIONAL_SYNC] Agent → Store: {agent_id}:{local_name} → {global_name} ({operation})")
+
+ if operation == "add" or operation == "update":
+ # 更新 Store 中的服务配置
+ await self._update_store_service_config(global_name, new_config)
+
+ elif operation == "delete":
+ # 从 Store 中删除服务
+ await self._delete_store_service(global_name)
+
+ logger.info(f"[BIDIRECTIONAL_SYNC] [COMPLETE] Agent -> Store sync completed: {sync_key}")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Agent -> Store sync failed {sync_key}: {e}")
+ finally:
+ self._syncing_services.discard(sync_key)
+
+ async def sync_store_to_agent(self, global_name: str, new_config: Dict[str, Any], operation: str = "update"):
+ """
+ Store 配置变更同步到对应的 Agent
+
+ Args:
+ global_name: Store 中的全局服务名
+ new_config: 新的服务配置
+ operation: 操作类型 ("add", "update", "delete")
+ """
+ sync_key = f"store:{global_name}:{operation}"
+ if sync_key in self._syncing_services:
+ logger.debug(f" [BIDIRECTIONAL_SYNC] Skipping recursive sync: {sync_key}")
+ return
+
+ try:
+ self._syncing_services.add(sync_key)
+
+ # 检查是否为 Agent 服务
+ if not AgentServiceMapper.is_any_agent_service(global_name):
+ logger.debug(f" [BIDIRECTIONAL_SYNC] Not an Agent service: {global_name}")
+ return
+
+ # 解析 Agent 信息
+ agent_id, local_name = AgentServiceMapper.parse_agent_service_name(global_name)
+
+ logger.info(f" [BIDIRECTIONAL_SYNC] Store → Agent: {global_name} → {agent_id}:{local_name} ({operation})")
+
+ if operation == "add" or operation == "update":
+ # 更新 Agent 中的服务配置
+ await self._update_agent_service_config(agent_id, local_name, new_config)
+
+ elif operation == "delete":
+ # 从 Agent 中删除服务
+ await self._delete_agent_service(agent_id, local_name)
+
+ logger.info(f"[BIDIRECTIONAL_SYNC] [COMPLETE] Store -> Agent sync completed: {sync_key}")
+
+ except Exception as e:
+ logger.error(f" [BIDIRECTIONAL_SYNC] Store -> Agent sync failed {sync_key}: {e}")
+ finally:
+ self._syncing_services.discard(sync_key)
+
+ async def handle_service_update_with_sync(self, agent_id: str, service_name: str, new_config: Dict[str, Any]):
+ """
+ 带同步的服务更新(统一入口)
+
+ Args:
+ agent_id: Agent ID(如果是 global_agent_store 则为 Store 操作)
+ service_name: 服务名
+ new_config: 新配置
+ """
+ try:
+ if agent_id == self.store.client_manager.global_agent_store_id:
+ # Store 操作:检查是否需要同步到 Agent
+ if AgentServiceMapper.is_any_agent_service(service_name):
+ await self.sync_store_to_agent(service_name, new_config, "update")
+ else:
+ # Agent 操作:同步到 Store
+ await self.sync_agent_to_store(agent_id, service_name, new_config, "update")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Service update sync failed {agent_id}:{service_name}: {e}")
+
+ async def handle_service_deletion_with_sync(self, agent_id: str, service_name: str):
+ """
+ 带同步的服务删除(统一入口)
+
+ Args:
+ agent_id: Agent ID(如果是 global_agent_store 则为 Store 操作)
+ service_name: 服务名
+ """
+ try:
+ if agent_id == self.store.client_manager.global_agent_store_id:
+ # Store 操作:检查是否需要同步到 Agent
+ if AgentServiceMapper.is_any_agent_service(service_name):
+ await self.sync_store_to_agent(service_name, {}, "delete")
+ else:
+ # Agent 操作:同步到 Store
+ await self.sync_agent_to_store(agent_id, service_name, {}, "delete")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Service deletion sync failed {agent_id}:{service_name}: {e}")
+
+ # === 内部同步实现方法 ===
+
+ async def _update_store_service_config(self, global_name: str, new_config: Dict[str, Any]):
+ """更新 Store 中的服务配置"""
+ try:
+ # 1. 更新 Registry 中的配置
+ if hasattr(self.store.registry, 'update_service_config'):
+ self.store.registry.update_service_config(
+ self.store.client_manager.global_agent_store_id,
+ global_name,
+ new_config
+ )
+
+ # 2. 更新 mcp.json(使用 UnifiedConfigManager 自动刷新缓存)
+ success = self.store._unified_config.add_service_config(global_name, new_config)
+
+ if success:
+ logger.debug(f"[BIDIRECTIONAL_SYNC] [SUCCESS] Store configuration update successful: {global_name}, cache synchronized")
+ else:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Store configuration update failed: {global_name}")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Failed to update Store service configuration {global_name}: {e}")
+ raise
+
+ async def _update_agent_service_config(self, agent_id: str, local_name: str, new_config: Dict[str, Any]):
+ """更新 Agent 中的服务配置"""
+ try:
+ # 更新 Registry 中的配置
+ if hasattr(self.store.registry, 'update_service_config'):
+ self.store.registry.update_service_config(agent_id, local_name, new_config)
+
+ logger.debug(f"[BIDIRECTIONAL_SYNC] [SUCCESS] Agent configuration update successful: {agent_id}:{local_name}")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Failed to update Agent service configuration {agent_id}:{local_name}: {e}")
+ raise
+
+ async def _delete_store_service(self, global_name: str):
+ """从 Store 中删除服务"""
+ try:
+ # 1. 从 Registry 中删除(使用异步版本)
+ await self.store.registry.remove_service_async(
+ self.store.client_manager.global_agent_store_id,
+ global_name
+ )
+
+ # 2. 从 mcp.json 中删除(使用 UnifiedConfigManager 自动刷新缓存)
+ success = self.store._unified_config.remove_service_config(global_name)
+
+ if success:
+ logger.debug(f"[BIDIRECTIONAL_SYNC] [SUCCESS] Store service deletion successful: {global_name}, cache synchronized")
+ else:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Store service deletion failed: {global_name}")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Failed to delete Store service {global_name}: {e}")
+ raise
+
+ async def _delete_agent_service(self, agent_id: str, local_name: str):
+ """从 Agent 中删除服务"""
+ try:
+ # 从 Registry 中删除(使用异步版本)
+ await self.store.registry.remove_service_async(agent_id, local_name)
+
+ # 移除映射关系
+ await self.store.registry.remove_agent_service_mapping_async(agent_id, local_name)
+
+ logger.debug(f"[BIDIRECTIONAL_SYNC] [SUCCESS] Agent service deletion successful: {agent_id}:{local_name}")
+
+ except Exception as e:
+ logger.error(f"[BIDIRECTIONAL_SYNC] [ERROR] Failed to delete Agent service {agent_id}:{local_name}: {e}")
+ raise
+
+ def get_sync_status(self) -> Dict[str, Any]:
+ """
+ 获取同步状态信息(用于调试和监控)
+
+ 遵循 pykv 数据唯一源原则:
+ - 不维护内存字典
+ - 主要数据从缓存源读取
+ - 内存仅存储会话信息和配置
+
+ Returns:
+ Dict: 同步状态信息
+
+ Note:
+ agent_mappings 已移除。如需服务映射信息,请使用:
+ - ServiceRegistry.get_agent_service_from_global_name_async()
+ - ServiceRegistry.get_global_name_from_agent_service_async()
+ """
+ return {
+ "currently_syncing": list(self._syncing_services),
+ "sync_count": len(self._syncing_services),
+ "store_id": self.store.client_manager.global_agent_store_id,
+ # agent_mappings 已移除:不再维护内存字典,所有映射从缓存源读取
+ }
diff --git a/src/mcpstore/core/sync/shared_client_state_sync.py b/src/mcpstore/core/sync/shared_client_state_sync.py
new file mode 100644
index 00000000..a393ef6a
--- /dev/null
+++ b/src/mcpstore/core/sync/shared_client_state_sync.py
@@ -0,0 +1,358 @@
+"""
+共享 Client ID 服务状态同步管理器
+
+处理共享同一 client_id 的服务之间的状态同步,确保 Agent 服务和 Store 中对应的
+带后缀服务状态保持一致。
+
+设计原则:
+1. 对生命周期管理器零侵入
+2. 自动透明同步
+3. 防止递归同步
+4. 详细的同步日志
+"""
+
+import asyncio
+import logging
+from typing import List, Tuple, Set, Optional, Dict
+
+from mcpstore.core.models.service import ServiceConnectionState
+
+logger = logging.getLogger(__name__)
+
+class SharedClientStateSyncManager:
+ """共享 Client ID 的服务状态同步管理器"""
+
+ def __init__(self, registry):
+ """
+ 初始化状态同步管理器
+
+ Args:
+ registry: ServiceRegistry 实例
+ """
+ self.registry = registry
+ self._syncing: Set[Tuple[str, str]] = set() # 防止递归同步的标记
+ self._sync_lock = asyncio.Lock() # 🆕 原子同步锁
+ self._batch_sync_queue: Dict[str, List[Tuple[str, str, ServiceConnectionState]]] = {} # 🆕 批量同步队列
+
+ def sync_state_for_shared_client(self, agent_id: str, service_name: str, new_state: ServiceConnectionState):
+ """
+ 为共享 Client ID 的服务同步状态
+
+ Args:
+ agent_id: 触发状态变更的服务所属 Agent ID
+ service_name: 触发状态变更的服务名
+ new_state: 新的服务状态
+ """
+ # 防止递归同步
+ sync_key = (agent_id, service_name)
+ if sync_key in self._syncing:
+ logger.debug(f" [STATE_SYNC] Skipping recursive sync for {agent_id}:{service_name}")
+ return
+
+ try:
+ self._syncing.add(sync_key)
+
+ # 获取服务的 client_id
+ client_id = self.registry._agent_client_service.get_service_client_id(agent_id, service_name)
+ if not client_id:
+ logger.debug(f" [STATE_SYNC] No client_id found for {agent_id}:{service_name}")
+ return
+
+ # 查找所有使用相同 client_id 的服务
+ shared_services = self._find_all_services_with_client_id(client_id)
+
+ if len(shared_services) <= 1:
+ logger.debug(f" [STATE_SYNC] No shared services found for client_id {client_id}")
+ return
+
+ # 同步状态到所有共享服务(排除触发源)
+ synced_count = 0
+ for target_agent_id, target_service_name in shared_services:
+ if (target_agent_id, target_service_name) != (agent_id, service_name):
+ # 获取目标服务的当前状态
+ current_state = self.registry._service_state_service.get_service_state(target_agent_id, target_service_name)
+
+ if current_state != new_state:
+ # 直接设置状态,避免触发递归同步
+ self._set_state_directly(target_agent_id, target_service_name, new_state)
+ synced_count += 1
+ logger.debug(f" [STATE_SYNC] Synced {new_state.value}: {agent_id}:{service_name} → {target_agent_id}:{target_service_name}")
+ else:
+ logger.debug(f" [STATE_SYNC] State already synced for {target_agent_id}:{target_service_name}")
+
+ if synced_count > 0:
+ logger.info(f" [STATE_SYNC] Synced state {new_state.value} to {synced_count} shared services for client_id {client_id}")
+ else:
+ logger.debug(f" [STATE_SYNC] No sync needed for client_id {client_id}")
+
+ except Exception as e:
+ logger.error(f" [STATE_SYNC] Failed to sync state for {agent_id}:{service_name}: {e}")
+ finally:
+ self._syncing.discard(sync_key)
+
+ def _find_all_services_with_client_id(self, client_id: str) -> List[Tuple[str, str]]:
+ """
+ 查找使用指定 client_id 的所有服务 (从 pyvk 读取)
+
+ Args:
+ client_id: 要查找的 Client ID
+
+ Returns:
+ List of (agent_id, service_name) tuples
+ """
+ services = []
+
+ # Get all agent_ids from in-memory cache (still needed for iteration)
+ agent_ids = self.registry.get_all_agent_ids()
+
+ # For each agent, get service-client mappings from pyvk
+ for agent_id in agent_ids:
+ try:
+ service_mappings = self.registry._agent_client_service.get_service_client_mapping(agent_id)
+ for service_name, mapped_client_id in service_mappings.items():
+ if mapped_client_id == client_id:
+ services.append((agent_id, service_name))
+ except Exception as e:
+ logger.warning(f"[STATE_SYNC] Failed to get service mappings for {agent_id}: {e}")
+
+ logger.debug(f" [STATE_SYNC] Found {len(services)} services with client_id {client_id}: {services}")
+ return services
+
+ def _set_state_directly(self, agent_id: str, service_name: str, state: ServiceConnectionState):
+ """
+ 直接设置状态,不触发同步(避免递归)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名
+ state: 新状态
+ """
+ if agent_id not in self.registry.service_states:
+ self.registry.service_states[agent_id] = {}
+
+ self.registry.service_states[agent_id][service_name] = state
+ logger.debug(f" [STATE_SYNC] Direct state set: {agent_id}:{service_name} → {state.value}")
+
+ def get_shared_services_info(self, agent_id: str, service_name: str) -> Optional[dict]:
+ """
+ 获取共享服务信息(用于调试和监控)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名
+
+ Returns:
+ 共享服务信息字典,如果没有共享服务则返回 None
+ """
+ try:
+ client_id = self.registry._agent_client_service.get_service_client_id(agent_id, service_name)
+ if not client_id:
+ return None
+
+ shared_services = self._find_all_services_with_client_id(client_id)
+ if len(shared_services) <= 1:
+ return None
+
+ # 收集所有共享服务的状态信息
+ services_info = []
+ for svc_agent_id, svc_service_name in shared_services:
+ state = self.registry._service_state_service.get_service_state(svc_agent_id, svc_service_name)
+ services_info.append({
+ "agent_id": svc_agent_id,
+ "service_name": svc_service_name,
+ "state": state.value if state else "unknown"
+ })
+
+ return {
+ "client_id": client_id,
+ "shared_services_count": len(shared_services),
+ "services": services_info
+ }
+
+ except Exception as e:
+ logger.error(f" [STATE_SYNC] Failed to get shared services info for {agent_id}:{service_name}: {e}")
+ return None
+
+ async def atomic_state_update(self, agent_id: str, service_name: str, new_state: ServiceConnectionState):
+ """
+ 原子状态更新,确保所有共享服务同步更新
+
+ Args:
+ agent_id: 触发状态变更的服务所属 Agent ID
+ service_name: 触发状态变更的服务名
+ new_state: 新的服务状态
+ """
+ async with self._sync_lock:
+ try:
+ logger.debug(f" [ATOMIC_SYNC] Starting atomic state update: {agent_id}:{service_name} -> {new_state.value}")
+
+ # 获取服务的 client_id
+ client_id = self.registry._agent_client_service.get_service_client_id(agent_id, service_name)
+ if not client_id:
+ logger.debug(f" [ATOMIC_SYNC] No client_id found for {agent_id}:{service_name}")
+ return
+
+ # 查找所有使用相同 client_id 的服务
+ shared_services = self._find_all_services_with_client_id(client_id)
+
+ if len(shared_services) <= 1:
+ logger.debug(f" [ATOMIC_SYNC] No shared services found for client_id {client_id}")
+ # 只有一个服务,直接更新
+ self._set_state_directly(agent_id, service_name, new_state)
+ return
+
+ # 原子更新所有共享服务的状态
+ updated_count = 0
+ for target_agent_id, target_service_name in shared_services:
+ self._set_state_directly(target_agent_id, target_service_name, new_state)
+ updated_count += 1
+ logger.debug(f" [ATOMIC_SYNC] Updated {target_agent_id}:{target_service_name} -> {new_state.value}")
+
+ logger.info(f" [ATOMIC_SYNC] Atomic update completed: {updated_count} services updated to {new_state.value} for client_id {client_id}")
+
+ except Exception as e:
+ logger.error(f" [ATOMIC_SYNC] Failed atomic state update for {agent_id}:{service_name}: {e}")
+ raise
+
+ def validate_state_consistency(self, client_id: str) -> Dict[str, any]:
+ """
+ 验证共享client_id的所有服务状态是否一致
+
+ Args:
+ client_id: 要验证的 Client ID
+
+ Returns:
+ Dict: 验证结果
+ - consistent: bool 是否一致
+ - services: List 所有服务状态
+ - inconsistent_services: List 状态不一致的服务
+ """
+ try:
+ logger.debug(f" [STATE_VALIDATION] Validating state consistency for client_id: {client_id}")
+
+ # 查找所有使用该 client_id 的服务
+ shared_services = self._find_all_services_with_client_id(client_id)
+
+ if len(shared_services) <= 1:
+ return {
+ "consistent": True,
+ "services": shared_services,
+ "inconsistent_services": [],
+ "message": f"Only {len(shared_services)} service(s) found, consistency check not applicable"
+ }
+
+ # 收集所有服务的状态
+ service_states = []
+ state_groups = {}
+
+ for agent_id, service_name in shared_services:
+ state = self.registry._service_state_service.get_service_state(agent_id, service_name)
+ state_value = state.value if state else "unknown"
+
+ service_states.append({
+ "agent_id": agent_id,
+ "service_name": service_name,
+ "state": state_value
+ })
+
+ # 按状态分组
+ if state_value not in state_groups:
+ state_groups[state_value] = []
+ state_groups[state_value].append((agent_id, service_name))
+
+ # 检查一致性
+ is_consistent = len(state_groups) == 1
+ inconsistent_services = []
+
+ if not is_consistent:
+ # 找出不一致的服务(非主要状态的服务)
+ main_state = max(state_groups.keys(), key=lambda k: len(state_groups[k]))
+ for state_value, services in state_groups.items():
+ if state_value != main_state:
+ inconsistent_services.extend(services)
+
+ result = {
+ "consistent": is_consistent,
+ "services": service_states,
+ "inconsistent_services": inconsistent_services,
+ "state_groups": state_groups,
+ "message": f"Consistency check completed for {len(shared_services)} services"
+ }
+
+ if is_consistent:
+ logger.info(f" [STATE_VALIDATION] State consistency validated for client_id {client_id}: ALL CONSISTENT")
+ else:
+ logger.warning(f"[STATE_VALIDATION] [WARN] State inconsistency detected for client_id {client_id}: {len(inconsistent_services)} services inconsistent")
+
+ return result
+
+ except Exception as e:
+ logger.error(f" [STATE_VALIDATION] Failed to validate state consistency for client_id {client_id}: {e}")
+ return {
+ "consistent": False,
+ "services": [],
+ "inconsistent_services": [],
+ "error": str(e),
+ "message": "Validation failed due to error"
+ }
+
+ async def batch_sync_client_states(self, client_id: str, target_state: ServiceConnectionState):
+ """
+ 批量同步指定client_id的所有服务到目标状态
+
+ Args:
+ client_id: Client ID
+ target_state: 目标状态
+ """
+ async with self._sync_lock:
+ try:
+ logger.info(f" [BATCH_SYNC] Starting batch sync for client_id {client_id} to {target_state.value}")
+
+ # 查找所有使用该 client_id 的服务
+ shared_services = self._find_all_services_with_client_id(client_id)
+
+ if not shared_services:
+ logger.warning(f"[BATCH_SYNC] [WARN] No services found for client_id {client_id}")
+ return
+
+ # 批量更新所有服务状态
+ updated_count = 0
+ for agent_id, service_name in shared_services:
+ current_state = self.registry._service_state_service.get_service_state(agent_id, service_name)
+ if current_state != target_state:
+ self._set_state_directly(agent_id, service_name, target_state)
+ updated_count += 1
+ logger.debug(f" [BATCH_SYNC] Updated {agent_id}:{service_name}: {current_state} -> {target_state.value}")
+ else:
+ logger.debug(f" [BATCH_SYNC] Skipped {agent_id}:{service_name}: already {target_state.value}")
+
+ logger.info(f" [BATCH_SYNC] Batch sync completed: {updated_count}/{len(shared_services)} services updated for client_id {client_id}")
+
+ except Exception as e:
+ logger.error(f" [BATCH_SYNC] Failed batch sync for client_id {client_id}: {e}")
+ raise
+
+ def _set_state_directly(self, agent_id: str, service_name: str, new_state: ServiceConnectionState):
+ """
+ 直接设置服务状态,绕过同步机制(用于内部原子操作)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名
+ new_state: 新状态
+ """
+ try:
+ # 直接更新registry中的状态,不触发同步
+ if agent_id in self.registry.service_states:
+ if service_name in self.registry.service_states[agent_id]:
+ old_state = self.registry.service_states[agent_id][service_name]
+ self.registry.service_states[agent_id][service_name] = new_state
+ logger.debug(f" [DIRECT_SET] {agent_id}:{service_name} state: {old_state} -> {new_state.value}")
+ else:
+ logger.warning(f"[DIRECT_SET] [WARN] Service {service_name} not found in agent {agent_id}")
+ else:
+ logger.warning(f"[DIRECT_SET] [WARN] Agent {agent_id} not found in service_states")
+
+ except Exception as e:
+ logger.error(f" [DIRECT_SET] Failed to set state directly for {agent_id}:{service_name}: {e}")
+ raise
diff --git a/src/mcpstore/core/sync/unified_sync_manager.py b/src/mcpstore/core/sync/unified_sync_manager.py
new file mode 100644
index 00000000..6e2cd785
--- /dev/null
+++ b/src/mcpstore/core/sync/unified_sync_manager.py
@@ -0,0 +1,619 @@
+"""
+Unified MCP Configuration Synchronization Manager
+
+Core design principles:
+1. mcp.json is the single source of truth
+2. All configuration changes go through mcp.json, automatically sync to global_agent_store
+3. Agent operations only manage their own space + mcp.json, Store operations only manage mcp.json
+4. Automatic sync mechanism handles mcp.json → global_agent_store synchronization
+
+Data space support:
+- File monitoring based on orchestrator.mcp_config.json_path
+- Support independent synchronization for different data spaces
+"""
+
+import asyncio
+import logging
+import os
+import time
+from typing import Dict, Any
+
+from mcpstore.utils.watchdog.events import FileSystemEventHandler
+# 强制使用内置 watchdog(不再可选)
+from mcpstore.utils.watchdog.observers import Observer
+
+logger = logging.getLogger(__name__)
+
+
+class MCPFileHandler(FileSystemEventHandler):
+ """MCP configuration file change handler"""
+
+ def __init__(self, sync_manager):
+ self.sync_manager = sync_manager
+ self.mcp_filename = os.path.basename(sync_manager.mcp_json_path)
+
+ def on_modified(self, event):
+ """File modification event handling"""
+ if event.is_directory:
+ return
+
+ # Only monitor target mcp.json file
+ if os.path.basename(event.src_path) == self.mcp_filename:
+ logger.debug(f"MCP config file modified: {event.src_path}")
+ # Safely execute async method in correct event loop
+ try:
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # If event loop is running, use call_soon_threadsafe
+ loop.call_soon_threadsafe(
+ lambda: asyncio.create_task(self.sync_manager.on_file_changed())
+ )
+ else:
+ # 如果事件循环未运行,直接创建任务
+ asyncio.create_task(self.sync_manager.on_file_changed())
+ except RuntimeError:
+ # 如果没有事件循环,记录警告
+ logger.warning("No event loop available for file change notification")
+
+
+class UnifiedMCPSyncManager:
+ """统一的MCP配置同步管理器"""
+
+ def __init__(self, orchestrator):
+ """
+ 初始化同步管理器
+
+ Args:
+ orchestrator: MCPOrchestrator实例
+ """
+ self.orchestrator = orchestrator
+ # 确保使用绝对路径
+ import os
+ self.mcp_json_path = os.path.abspath(orchestrator.mcp_config.json_path)
+ self.file_observer = None
+ self.sync_lock = asyncio.Lock()
+ self.debounce_delay = 1.0 # 防抖延迟(秒)
+ self.sync_task = None
+ self.last_change_time = None
+ self.last_sync_time = None # 新增:记录上次同步时间
+ self.min_sync_interval = 5.0 # 新增:最小同步间隔(秒)
+ self.is_running = False
+
+ logger.info(f"UnifiedMCPSyncManager initialized for: {self.mcp_json_path}")
+
+ async def start(self):
+ """启动同步管理器"""
+ if self.is_running:
+ logger.warning("Sync manager is already running")
+ return
+
+ try:
+ logger.info("Starting unified MCP sync manager...")
+
+ # 启动文件监听
+ await self._start_file_watcher()
+
+ # 执行启动时同步(始终启用)
+ logger.info("Executing initial sync from mcp.json")
+ await self.sync_global_agent_store_from_mcp_json()
+
+ self.is_running = True
+ logger.info("Unified MCP sync manager started successfully")
+
+ except Exception as e:
+ logger.error(f"Failed to start sync manager: {e}")
+ await self.stop()
+ raise
+
+ async def stop(self):
+ """停止同步管理器"""
+ if not self.is_running:
+ return
+
+ logger.info("Stopping unified MCP sync manager...")
+
+ # 停止文件监听
+ if self.file_observer:
+ self.file_observer.stop()
+ self.file_observer.join()
+ self.file_observer = None
+
+ # 取消待执行的同步任务
+ if self.sync_task and not self.sync_task.done():
+ self.sync_task.cancel()
+
+ self.is_running = False
+ logger.info("Unified MCP sync manager stopped")
+
+ async def _start_file_watcher(self):
+ """启动mcp.json文件监听"""
+ try:
+ # 确保mcp.json文件存在
+ if not os.path.exists(self.mcp_json_path):
+ logger.warning(f"MCP config file not found: {self.mcp_json_path}")
+ # 创建空配置文件
+ os.makedirs(os.path.dirname(self.mcp_json_path), exist_ok=True)
+ with open(self.mcp_json_path, 'w', encoding='utf-8') as f:
+ import json
+ json.dump({"mcpServers": {}}, f, indent=2)
+ logger.info(f"Created empty MCP config file: {self.mcp_json_path}")
+
+ # 创建文件监听器
+ self.file_observer = Observer()
+ handler = MCPFileHandler(self)
+
+ # 监听mcp.json所在目录
+ watch_dir = os.path.dirname(self.mcp_json_path)
+ self.file_observer.schedule(handler, watch_dir, recursive=False)
+ self.file_observer.start()
+
+ logger.info(f"File watcher started for directory: {watch_dir}")
+
+ except Exception as e:
+ logger.error(f"Failed to start file watcher: {e}")
+ raise
+
+ async def on_file_changed(self):
+ """文件变化回调(带防抖)"""
+ try:
+ self.last_change_time = time.time()
+
+ # 取消之前的同步任务
+ if self.sync_task and not self.sync_task.done():
+ self.sync_task.cancel()
+
+ # 启动防抖同步
+ self.sync_task = asyncio.create_task(self._debounced_sync())
+
+ except Exception as e:
+ logger.error(f"Error handling file change: {e}")
+
+ async def _debounced_sync(self):
+ """防抖同步"""
+ try:
+ await asyncio.sleep(self.debounce_delay)
+
+ # 检查是否有新的变化
+ if self.last_change_time and time.time() - self.last_change_time >= self.debounce_delay:
+ logger.info("Triggering auto-sync due to mcp.json changes")
+ # 统一使用全局同步方法
+ await self.sync_global_agent_store_from_mcp_json()
+
+ except asyncio.CancelledError:
+ logger.debug("Debounced sync cancelled")
+ except Exception as e:
+ logger.error(f"Error in debounced sync: {e}")
+
+ async def sync_global_agent_store_from_mcp_json(self):
+ """从mcp.json同步global_agent_store(核心方法)"""
+ async with self.sync_lock:
+ try:
+ # 新增:检查同步频率,避免过度同步
+ current_time = time.time()
+
+ if self.last_sync_time and (current_time - self.last_sync_time) < self.min_sync_interval:
+ logger.debug(f"Sync skipped due to frequency limit (last sync {current_time - self.last_sync_time:.1f}s ago)")
+ return {"skipped": True, "reason": "frequency_limit"}
+
+ logger.info("Starting global_agent_store sync from mcp.json")
+
+ config = self.orchestrator.mcp_config.load_config()
+ services = config.get("mcpServers", {})
+
+ sync_manager = getattr(self.orchestrator, "config_sync_manager", None)
+ if sync_manager is not None:
+ try:
+ await sync_manager.sync_json_to_cache(self.mcp_json_path, overwrite=True)
+ except Exception as e:
+ logger.warning(f"JSON to KV sync failed during global sync: {e}")
+
+ logger.debug(f"Found {len(services)} services in mcp.json")
+
+ # 执行同步
+ results = await self._sync_global_agent_store_services(services)
+
+ # 新增:记录同步时间
+ self.last_sync_time = current_time
+
+ logger.info(f"Global agent store sync completed: {results}")
+ return results
+
+ except Exception as e:
+ logger.error(f"Global agent store sync failed: {e}")
+ raise
+
+ async def _sync_global_agent_store_services(self, target_services: Dict[str, Any]) -> Dict[str, Any]:
+ """同步global_agent_store的服务"""
+ try:
+ global_agent_store_id = self.orchestrator.client_manager.global_agent_store_id
+
+ # 获取当前global_agent_store的服务
+ current_services = await self._get_current_global_agent_store_services()
+
+ # 计算差异
+ current_names = set(current_services.keys())
+ target_names = set(target_services.keys())
+
+ to_add = target_names - current_names
+ to_remove = current_names - target_names
+ to_update = target_names & current_names
+
+ logger.debug(f"Sync plan: +{len(to_add)} -{len(to_remove)} ~{len(to_update)}")
+
+ # 执行同步
+ results = {
+ "added": [],
+ "removed": [],
+ "updated": [],
+ "failed": []
+ }
+
+ # 1. 移除不再需要的服务
+ for service_name in to_remove:
+ try:
+ success = await self._remove_service_from_global_agent_store(service_name)
+ if success:
+ results["removed"].append(service_name)
+ logger.debug(f"Removed service: {service_name}")
+ else:
+ results["failed"].append(f"remove:{service_name}")
+ except Exception as e:
+ logger.error(f"Failed to remove service {service_name}: {e}")
+ results["failed"].append(f"remove:{service_name}:{e}")
+
+ # 2. 添加/更新服务(改进逻辑:只处理真正需要变更的服务)
+ services_to_register = {}
+
+ # 处理新增服务
+ for service_name in to_add:
+ try:
+ success = await self._add_service_to_cache_mapping(
+ agent_id=global_agent_store_id,
+ service_name=service_name,
+ service_config=target_services[service_name]
+ )
+
+ if success:
+ services_to_register[service_name] = target_services[service_name]
+ results["added"].append(service_name)
+ logger.debug(f"Added new service to cache: {service_name}")
+ else:
+ results["failed"].append(f"add:{service_name}")
+
+ except Exception as e:
+ logger.error(f"Failed to add service {service_name}: {e}")
+ results["failed"].append(f"add:{service_name}:{e}")
+
+ # 处理更新服务(只有配置真正变化时才更新)
+ for service_name in to_update:
+ try:
+ # 检查配置是否真的有变化
+ current_config = current_services.get(service_name, {})
+ target_config = target_services[service_name]
+
+ if self._service_config_changed(current_config, target_config):
+ success = await self._add_service_to_cache_mapping(
+ agent_id=global_agent_store_id,
+ service_name=service_name,
+ service_config=target_config
+ )
+
+ if success:
+ services_to_register[service_name] = target_config
+ results["updated"].append(service_name)
+ logger.debug(f"Updated service in cache: {service_name}")
+ else:
+ results["failed"].append(f"update:{service_name}")
+ else:
+ logger.debug(f"Service {service_name} config unchanged, skipping update")
+
+ except Exception as e:
+ logger.error(f"Failed to update service {service_name}: {e}")
+ results["failed"].append(f"update:{service_name}:{e}")
+
+ # 3. 批量注册到Registry(只注册真正需要注册的服务)
+ if services_to_register:
+ logger.info(f"Registering {len(services_to_register)} services to Registry: {list(services_to_register.keys())}")
+ await self._batch_register_to_registry(global_agent_store_id, services_to_register)
+ else:
+ logger.debug("No services need to be registered to Registry")
+
+ # 4. 新增:触发缓存到文件的异步持久化
+ if services_to_register:
+ await self._trigger_cache_persistence()
+
+ return results
+
+ except Exception as e:
+ logger.error(f"Error syncing main client services: {e}")
+ raise
+
+ async def _get_current_global_agent_store_services(self) -> Dict[str, Any]:
+ """获取当前global_agent_store的服务配置"""
+ try:
+ # single-source: derive current services from registry cache layer
+ agent_id = self.orchestrator.client_manager.global_agent_store_id
+ current_services = {}
+ try:
+ # 使用 _cache_layer_manager(CacheLayerManager)获取所有服务实体
+ # 不再使用 _cache_layer,因为它在 Redis 模式下是 RedisStore,没有 get_all_entities_async 方法
+ service_entities = await self.orchestrator.registry._cache_layer_manager.get_all_entities_async("services")
+
+ for entity_key, entity_data in service_entities.items():
+ if hasattr(entity_data, 'value'):
+ data = entity_data.value
+ elif isinstance(entity_data, dict):
+ data = entity_data
+ else:
+ continue
+
+ # 只处理指定agent_id的服务
+ if data.get('source_agent') == agent_id:
+ service_name = data.get('service_original_name', entity_key)
+ config = self.orchestrator.mcp_config.get_service_config(service_name) or {}
+ if config:
+ current_services[service_name] = config
+
+ except Exception as e:
+ logger.error(f"Failed to get current service: {e}")
+ current_services = {}
+
+ return current_services
+
+ except Exception as e:
+ logger.error(f"Error getting current main client services: {e}")
+ return {}
+
+ async def _remove_service_from_global_agent_store(self, service_name: str) -> bool:
+ """从global_agent_store移除服务"""
+ try:
+ global_agent_store_id = self.orchestrator.client_manager.global_agent_store_id
+
+ # 查找包含该服务的client_ids
+ matching_clients = self.orchestrator.client_manager.find_clients_with_service(
+ global_agent_store_id, service_name
+ )
+
+ # 移除包含该服务的clients
+ for client_id in matching_clients:
+ self.orchestrator.client_manager._remove_client_and_mapping(global_agent_store_id, client_id)
+ logger.debug(f"Removed client {client_id} containing service {service_name}")
+
+ # 从Registry移除(使用异步版本)
+ if hasattr(self.orchestrator.registry, 'remove_service_async'):
+ await self.orchestrator.registry.remove_service_async(global_agent_store_id, service_name)
+
+ return len(matching_clients) > 0
+
+ except Exception as e:
+ logger.error(f"Error removing service {service_name} from main client: {e}")
+ return False
+
+ async def _batch_register_to_registry(self, agent_id: str, services_to_register: Dict[str, Any]):
+ """批量注册服务到Registry(改进版:避免重复注册)"""
+ try:
+ if not services_to_register:
+ return
+
+ logger.debug(f"Batch registering {len(services_to_register)} services to Registry")
+ registered_count = 0
+ skipped_count = 0
+ event_bus = getattr(getattr(self.orchestrator, "container", None), "_event_bus", None) or getattr(self.orchestrator, "event_bus", None)
+
+ for service_name, config in services_to_register.items():
+ if await self.orchestrator.registry.has_service_async(agent_id, service_name):
+ skipped_count += 1
+ continue
+ try:
+ if not event_bus:
+ raise RuntimeError("EventBus unavailable for bootstrap registration")
+
+ from mcpstore.core.events.service_events import ServiceBootstrapRequested
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=config,
+ global_agent_store_id=getattr(self.orchestrator.client_manager, "global_agent_store_id", "global_agent_store")
+ )
+
+ bootstrap_event = ServiceBootstrapRequested(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=config,
+ client_id=client_id,
+ global_name=service_name,
+ origin_agent_id=agent_id,
+ origin_local_name=service_name,
+ source="sync_mcpjson"
+ )
+ await event_bus.publish(bootstrap_event, wait=False)
+ registered_count += 1
+ except Exception as e:
+ logger.error(f"Failed to register service {service_name}: {e}")
+
+ logger.info(f"Batch registration completed (bootstrap path): {registered_count} registered, {skipped_count} skipped")
+
+ except Exception as e:
+ logger.error(f"Error in batch register to registry: {e}")
+
+ async def _add_service_to_cache_mapping(self, agent_id: str, service_name: str, service_config: Dict[str, Any]) -> bool:
+ """
+ 将服务添加到缓存映射(Registry中的映射字段)
+
+ 注意:
+ - registry.agent_clients: 已移除 (Phase 4) - 现在从 pyvk 推导
+ - registry.clients: 已移除 (Phase 5) - 现在存储在 pyvk
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+ service_config: 服务配置
+
+ Returns:
+ 是否成功添加到缓存映射
+ """
+ try:
+ # 获取Registry实例
+ registry = getattr(self.orchestrator, 'registry', None)
+ if not registry:
+ logger.error("Registry not available")
+ return False
+
+ # 修复:检查是否已存在该服务的client_id,避免重复生成
+ existing_client_id = await self._find_existing_client_id_for_service_async(agent_id, service_name)
+
+ if existing_client_id:
+ # 使用现有的client_id,只更新配置
+ client_id = existing_client_id
+ logger.debug(f" Using existing client_id: {service_name} -> {client_id}")
+ else:
+ # 使用统一的ClientIDGenerator生成确定性client_id
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+ # UnifiedMCPSyncManager主要处理Store级别的服务,所以使用global_agent_store_id
+ global_agent_store_id = getattr(self.orchestrator.client_manager, 'global_agent_store_id', 'global_agent_store')
+
+ client_id = ClientIDGenerator.generate_deterministic_id(
+ agent_id=agent_id,
+ service_name=service_name,
+ service_config=service_config,
+ global_agent_store_id=global_agent_store_id
+ )
+ logger.debug(f" Generating new client_id: {service_name} -> {client_id}")
+
+ # 更新缓存映射1:Agent-Client映射(通过Registry公共API)
+ try:
+ registry._agent_client_service.add_agent_client_mapping(agent_id, client_id)
+ except Exception as e:
+ logger.error(f"Failed to add agent-client mapping for {agent_id}/{client_id}: {e}")
+ return False
+
+ # 更新缓存映射2:Client实体(使用 main_registry 格式)
+ try:
+ # 获取或创建 client 实体(使用 main_registry 格式)
+ client_entity = await registry._cache_layer_manager.get_entity("clients", client_id)
+ if not isinstance(client_entity, dict):
+ # 创建新的 client 实体
+ client_entity = {
+ "client_id": client_id,
+ "agent_id": agent_id,
+ "services": [],
+ "created_time": int(time.time()),
+ }
+
+ # 更新 services 列表
+ services = client_entity.get("services") or []
+ if service_name not in services:
+ services.append(service_name)
+
+ client_entity.update({
+ "agent_id": agent_id,
+ "services": services,
+ "updated_time": int(time.time()),
+ })
+
+ await registry._cache_layer_manager.put_entity("clients", client_id, client_entity)
+ except Exception as e:
+ logger.error(f"Failed to create/update client entity for {client_id}: {e}")
+ raise # 按要求抛出错误,不做静默处理
+
+ logger.debug(f"Cache mapping updated successfully: {service_name} -> {client_id}")
+ logger.debug(f" - agent_clients[{agent_id}] updated via Registry API")
+ logger.debug(f" - clients[{client_id}] updated via Registry API")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to add service to cache mapping: {e}")
+ return False
+
+ async def _find_existing_client_id_for_service_async(self, agent_id: str, service_name: str) -> str:
+ """
+ 查找指定服务是否已有对应的client_id(异步版本)
+
+ Args:
+ agent_id: Agent ID
+ service_name: 服务名称
+
+ Returns:
+ 现有的client_id,如果不存在则返回None
+ """
+ try:
+ registry = getattr(self.orchestrator, 'registry', None)
+ if not registry:
+ return None
+
+ # 获取该agent的所有client_id(通过Registry公共API)- 从 pykv 获取
+ client_ids = await registry.get_agent_clients_async(agent_id)
+
+ # 遍历每个client_id,检查是否包含目标服务(使用新格式:services 列表)
+ for client_id in client_ids:
+ client_entity = await registry.get_client_config_from_cache_async(client_id)
+ if client_entity and isinstance(client_entity, dict):
+ services = client_entity.get("services", [])
+ if service_name in services:
+ logger.debug(f" Found existing client_id: {service_name} -> {client_id}")
+ return client_id
+
+ return None
+
+ except Exception as e:
+ logger.error(f"Error finding existing client_id for service {service_name}: {e}")
+ return None
+
+ def _service_config_changed(self, current_config: Dict[str, Any], target_config: Dict[str, Any]) -> bool:
+ """
+ 检查服务配置是否发生变化
+
+ Args:
+ current_config: 当前配置
+ target_config: 目标配置
+
+ Returns:
+ 配置是否发生变化
+ """
+ try:
+ # 简单的字典比较,可以根据需要扩展
+ import json
+ current_str = json.dumps(current_config, sort_keys=True)
+ target_str = json.dumps(target_config, sort_keys=True)
+ changed = current_str != target_str
+
+ if changed:
+ logger.debug(f"Service config changed: {current_str} -> {target_str}")
+
+ return changed
+
+ except Exception as e:
+ logger.error(f"Error comparing service configs: {e}")
+ # 出错时保守处理,认为有变化
+ return True
+
+ async def _trigger_cache_persistence(self):
+ """
+ 触发缓存映射到文件的同步机制
+
+ 注意:这里调用的是同步机制(sync_to_client_manager),
+ 不是异步持久化(_persist_to_files_async)
+ """
+ try:
+ # 单源模式:不再将缓存映射同步到分片文件
+ logger.debug("Single-source mode: skip shard mapping sync (agent_clients/client_services)")
+ except Exception as e:
+ logger.error(f"Failed in shard sync skip path: {e}")
+
+ async def manual_sync(self) -> Dict[str, Any]:
+ """手动触发同步(用于API调用)"""
+ logger.info("Manual sync triggered")
+ return await self.sync_global_agent_store_from_mcp_json()
+
+ def get_sync_status(self) -> Dict[str, Any]:
+ """获取同步状态信息"""
+ return {
+ "is_running": self.is_running,
+ "mcp_json_path": self.mcp_json_path,
+ "last_change_time": self.last_change_time,
+ "sync_lock_locked": self.sync_lock.locked(),
+ "file_observer_running": self.file_observer is not None and self.file_observer.is_alive() if self.file_observer else False
+ }
diff --git a/src/mcpstore/core/utils/__init__.py b/src/mcpstore/core/utils/__init__.py
new file mode 100644
index 00000000..39a7619a
--- /dev/null
+++ b/src/mcpstore/core/utils/__init__.py
@@ -0,0 +1,23 @@
+"""
+MCPStore Utils Package
+Common utility functions and classes
+"""
+
+from mcpstore.core.exceptions import (
+ ConfigurationException as ConfigurationError,
+ ServiceConnectionError,
+ ToolExecutionError
+)
+from .id_generator import generate_id, generate_short_id, generate_uuid
+
+__all__ = [
+ # 异常类
+ 'ConfigurationError',
+ 'ServiceConnectionError',
+ 'ToolExecutionError',
+ # ID 生成器
+ 'generate_id',
+ 'generate_short_id',
+ 'generate_uuid'
+]
+
diff --git a/src/mcpstore/core/utils/deadlock_safe_async_helper.py b/src/mcpstore/core/utils/deadlock_safe_async_helper.py
new file mode 100644
index 00000000..04a8672b
--- /dev/null
+++ b/src/mcpstore/core/utils/deadlock_safe_async_helper.py
@@ -0,0 +1,421 @@
+"""
+死锁安全的异步同步助手
+
+解决嵌套事件循环死锁的根本性修复方案
+"""
+
+import asyncio
+import logging
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+from typing import Any, Coroutine, Optional, Dict, List
+
+logger = logging.getLogger(__name__)
+
+
+class DeadlockSafeAsyncHelper:
+ """
+ 死锁安全的异步同步助手
+
+ 核心特性:
+ 1. 检测并防止嵌套调用死锁
+ 2. 重入锁机制支持递归调用
+ 3. 调用链追踪和超时检测
+ 4. 线程本地存储避免跨线程冲突
+ """
+
+ def __init__(self, max_concurrent_calls: int = 10, default_timeout: float = 30.0):
+ """
+ 初始化死锁安全的异步助手
+
+ Args:
+ max_concurrent_calls: 最大并发调用数
+ default_timeout: 默认超时时间
+ """
+ self.max_concurrent_calls = max_concurrent_calls
+ self.default_timeout = default_timeout
+
+ # 使用可重入锁而不是普通锁
+ self._lock = threading.RLock()
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._loop_thread: Optional[threading.Thread] = None
+
+ # 调用链追踪
+ self._active_calls: Dict[str, float] = {} # operation_name -> start_time
+ self._call_stack: threading.local = threading.local()
+
+ # 线程池执行器,避免创建过多线程
+ self._executor = ThreadPoolExecutor(
+ max_workers=4,
+ thread_name_prefix="deadlock_safe_async"
+ )
+
+ logger.debug("DeadlockSafeAsyncHelper initialized with reentrant lock")
+
+ def run_async(self, coro: Coroutine[Any, Any, Any], timeout: Optional[float] = None,
+ operation_name: str = "unknown", force_background: bool = False) -> Any:
+ """
+ 运行异步协程,防止死锁
+
+ Args:
+ coro: 要运行的协程
+ timeout: 超时时间
+ operation_name: 操作名称,用于调试
+ force_background: 是否强制使用后台线程
+
+ Returns:
+ 协程的执行结果
+
+ Raises:
+ RuntimeError: 如果检测到潜在的死锁
+ TimeoutError: 如果执行超时
+ """
+ if timeout is None:
+ timeout = self.default_timeout
+
+ import time as _t
+ start_time = _t.perf_counter()
+
+ try:
+ # 检查当前调用上下文
+ current_context = self._get_call_context()
+
+ # 检测潜在的死锁
+ if self._detect_potential_deadlock(operation_name, current_context):
+ raise RuntimeError(
+ f"Potential deadlock detected for operation '{operation_name}'. "
+ f"Current context: {current_context}, active calls: {list(self._active_calls.keys())}"
+ )
+
+ # 记录调用开始
+ self._record_call_start(operation_name, current_context)
+
+ try:
+ # 选择执行策略
+ if self._is_in_async_context():
+ result = self._run_in_existing_loop(coro, timeout, operation_name)
+ elif force_background:
+ result = self._run_in_background_thread(coro, timeout, operation_name)
+ else:
+ result = self._run_in_new_loop(coro, timeout, operation_name)
+
+ elapsed = _t.perf_counter() - start_time
+ logger.debug(f"[DEADLOCK_SAFE] {operation_name} completed in {elapsed:.3f}s")
+ return result
+
+ finally:
+ # 记录调用结束
+ self._record_call_end(operation_name)
+
+ except Exception as e:
+ elapsed = _t.perf_counter() - start_time
+ logger.error(f"[DEADLOCK_SAFE] {operation_name} failed after {elapsed:.3f}s: {e}")
+ raise
+
+ def _detect_potential_deadlock(self, operation_name: str, context: str) -> bool:
+ """
+ 检测潜在的死锁情况
+
+ Returns:
+ True 如果检测到潜在死锁
+ """
+ current_time = time.time()
+
+ # 检查1: 同一操作的重入
+ if operation_name in self._active_calls:
+ elapsed = current_time - self._active_calls[operation_name]
+ if elapsed < 1.0: # 1秒内的重入可能是死锁
+ logger.warning(f"[DEADLOCK_SAFE] Potential recursive deadlock: {operation_name}")
+ return True
+
+ # 检查2: 活动调用数量过多
+ if len(self._active_calls) >= self.max_concurrent_calls:
+ logger.warning(f"[DEADLOCK_SAFE] Too many concurrent calls: {len(self._active_calls)}")
+ return True
+
+ # 检查3: 长时间运行的操作
+ for op_name, start_time in self._active_calls.items():
+ if current_time - start_time > timeout * 0.8: # 80% 超时时间
+ logger.warning(f"[DEADLOCK_SAFE] Long running operation detected: {op_name}")
+ # 不直接返回False,允许继续但记录警告
+
+ return False
+
+ def _record_call_start(self, operation_name: str, context: str):
+ """记录调用开始"""
+ self._active_calls[operation_name] = time.time()
+
+ # 初始化线程本地的调用栈
+ if not hasattr(self._call_stack, 'stack'):
+ self._call_stack.stack = []
+
+ self._call_stack.stack.append({
+ 'operation': operation_name,
+ 'context': context,
+ 'start_time': time.time()
+ })
+
+ def _record_call_end(self, operation_name: str):
+ """记录调用结束"""
+ self._active_calls.pop(operation_name, None)
+
+ # 更新调用栈
+ if hasattr(self._call_stack, 'stack') and self._call_stack.stack:
+ call_info = self._call_stack.stack[-1]
+ if call_info['operation'] == operation_name:
+ self._call_stack.stack.pop()
+
+ def _get_call_context(self) -> str:
+ """获取当前调用上下文"""
+ try:
+ import inspect
+ frame = inspect.currentframe()
+ context_parts = []
+
+ # 获取调用栈信息
+ for _ in range(5): # 最多5层调用栈
+ frame = frame.f_back
+ if frame is None:
+ break
+
+ func_name = frame.f_code.co_name
+ filename = frame.f_code.co_filename
+ line_no = frame.f_lineno
+
+ # 简化文件名
+ simple_filename = filename.split('/')[-1] if '/' in filename else filename
+
+ context_parts.append(f"{func_name}({simple_filename}:{line_no})")
+
+ return " -> ".join(context_parts)
+
+ except Exception as e:
+ logger.debug(f"[DEADLOCK_SAFE] Failed to get call context: {e}")
+ return "unknown_context"
+
+ def _is_in_async_context(self) -> bool:
+ """检查是否在异步上下文中"""
+ try:
+ asyncio.get_running_loop()
+ return True
+ except RuntimeError:
+ return False
+
+ def _run_in_existing_loop(self, coro: Coroutine, timeout: float, operation_name: str) -> Any:
+ """在现有事件循环中运行"""
+ logger.debug(f"[DEADLOCK_SAFE] Running {operation_name} in existing async loop")
+
+ try:
+ # 使用 asyncio.create_task 在当前循环中调度
+ task = asyncio.create_task(coro)
+ return asyncio.wait_for(task, timeout=timeout)
+ except RuntimeError as e:
+ if "no running event loop" in str(e):
+ # 事件循环已经关闭,降级到后台线程
+ logger.warning(f"[DEADLOCK_SAFE] Event loop closed, falling back to background: {operation_name}")
+ return self._run_in_background_thread(coro, timeout, operation_name)
+ raise
+
+ def _run_in_background_thread(self, coro: Coroutine, timeout: float, operation_name: str) -> Any:
+ """在后台线程中运行"""
+ logger.debug(f"[DEADLOCK_SAFE] Running {operation_name} in background thread")
+
+ # 确保后台循环存在
+ loop = self._ensure_background_loop()
+
+ # 提交任务到后台循环
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
+
+ try:
+ return future.result(timeout=timeout)
+ except Exception as e:
+ logger.error(f"[DEADLOCK_SAFE] Background thread execution failed for {operation_name}: {e}")
+ raise
+
+ def _run_in_new_loop(self, coro: Coroutine, timeout: float, operation_name: str) -> Any:
+ """在新的事件循环中运行"""
+ logger.debug(f"[DEADLOCK_SAFE] Running {operation_name} in new event loop")
+
+ try:
+ return asyncio.run(coro)
+ except Exception as e:
+ logger.error(f"[DEADLOCK_SAFE] New loop execution failed for {operation_name}: {e}")
+ raise
+
+ def _ensure_background_loop(self) -> asyncio.AbstractEventLoop:
+ """确保后台事件循环存在"""
+ if self._loop is None or self._loop.is_closed():
+ with self._lock:
+ # 双重检查锁定
+ if self._loop is None or self._loop.is_closed():
+ self._create_background_loop()
+
+ return self._loop
+
+ def _create_background_loop(self):
+ """创建后台事件循环"""
+ loop_ready = threading.Event()
+
+ def run_loop():
+ """在独立线程中运行事件循环"""
+ try:
+ # 设置线程本地的事件循环
+ self._loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self._loop)
+
+ # 设置异常处理器
+ self._setup_exception_handler()
+
+ logger.debug("[DEADLOCK_SAFE] Background event loop started")
+ loop_ready.set()
+
+ # 运行事件循环
+ self._loop.run_forever()
+
+ except Exception as e:
+ logger.error(f"[DEADLOCK_SAFE] Background loop error: {e}")
+ finally:
+ logger.debug("[DEADLOCK_SAFE] Background event loop stopped")
+
+ # 启动后台线程
+ self._loop_thread = threading.Thread(
+ target=run_loop,
+ daemon=True,
+ name="deadlock_safe_event_loop"
+ )
+ self._loop_thread.start()
+
+ # 等待循环启动
+ if not loop_ready.wait(timeout=5):
+ raise RuntimeError("Failed to start background event loop")
+
+ def _setup_exception_handler(self):
+ """设置异常处理器"""
+ def _exception_handler(loop, context):
+ try:
+ exc = context.get("exception")
+ msg = context.get("message", "")
+
+ if exc is not None:
+ logger.warning(f"[DEADLOCK_SAFE] Background task error: {exc}")
+ else:
+ logger.warning(f"[DEADLOCK_SAFE] Background loop warning: {msg}")
+
+ except Exception:
+ # 异常处理器本身不应抛出异常
+ pass
+
+ self._loop.set_exception_handler(_exception_handler)
+
+ @contextmanager
+ def call_context(self, operation_name: str):
+ """
+ 调用上下文管理器,用于自动记录调用
+
+ Usage:
+ with async_helper.call_context("my_operation"):
+ result = await some_async_operation()
+ """
+ context = self._get_call_context()
+ self._record_call_start(operation_name, context)
+
+ try:
+ yield
+ finally:
+ self._record_call_end(operation_name)
+
+ def get_active_calls(self) -> Dict[str, Dict[str, Any]]:
+ """获取当前活动调用信息"""
+ current_time = time.time()
+ active_calls_info = {}
+
+ for op_name, start_time in self._active_calls.items():
+ active_calls_info[op_name] = {
+ "start_time": start_time,
+ "duration": current_time - start_time,
+ "status": "running"
+ }
+
+ return active_calls_info
+
+ def get_call_stack_info(self) -> List[Dict[str, Any]]:
+ """获取当前线程的调用栈信息"""
+ if not hasattr(self._call_stack, 'stack'):
+ return []
+
+ current_time = time.time()
+ stack_info = []
+
+ for call_info in self._call_stack.stack:
+ duration = current_time - call_info['start_time']
+ stack_info.append({
+ **call_info,
+ "duration": duration
+ })
+
+ return stack_info
+
+ def cleanup(self):
+ """清理资源"""
+ try:
+ logger.debug("[DEADLOCK_SAFE] Cleaning up resources")
+
+ # 取消所有活动调用
+ if self._active_calls:
+ logger.warning(f"[DEADLOCK_SAFE] {len(self._active_calls)} active calls during cleanup")
+
+ # 停止后台循环
+ if self._loop and not self._loop.is_closed():
+ self._loop.call_soon_threadsafe(self._loop.stop)
+
+ # 等待线程结束
+ if self._loop_thread and self._loop_thread.is_alive():
+ self._loop_thread.join(timeout=2)
+
+ # 关闭线程池
+ if self._executor:
+ self._executor.shutdown(wait=True, timeout=3)
+
+ logger.debug("[DEADLOCK_SAFE] Cleanup completed")
+
+ except Exception as e:
+ logger.error(f"[DEADLOCK_SAFE] Error during cleanup: {e}")
+
+ def __del__(self):
+ """析构函数"""
+ try:
+ self.cleanup()
+ except:
+ pass
+
+
+# 全局实例管理
+_global_deadlock_safe_helper = None
+_helper_lock = threading.Lock()
+
+
+def get_deadlock_safe_helper() -> DeadlockSafeAsyncHelper:
+ """获取全局的死锁安全异步助手实例"""
+ global _global_deadlock_safe_helper
+
+ if _global_deadlock_safe_helper is None:
+ with _helper_lock:
+ if _global_deadlock_safe_helper is None:
+ _global_deadlock_safe_helper = DeadlockSafeAsyncHelper()
+ logger.debug("Global DeadlockSafeAsyncHelper created")
+
+ return _global_deadlock_safe_helper
+
+
+def reset_deadlock_safe_helper():
+ """重置全局的死锁安全异步助手实例(用于测试)"""
+ global _global_deadlock_safe_helper
+
+ with _helper_lock:
+ if _global_deadlock_safe_helper is not None:
+ _global_deadlock_safe_helper.cleanup()
+ _global_deadlock_safe_helper = None
+
+ logger.debug("Global DeadlockSafeAsyncHelper reset")
\ No newline at end of file
diff --git a/src/mcpstore/core/utils/id_generator.py b/src/mcpstore/core/utils/id_generator.py
new file mode 100644
index 00000000..c274d353
--- /dev/null
+++ b/src/mcpstore/core/utils/id_generator.py
@@ -0,0 +1,176 @@
+"""
+Client ID Generator Module
+Provides unified and deterministic client ID generation for MCPStore
+"""
+
+import hashlib
+import logging
+import random
+import string
+import uuid
+from typing import Dict, Any
+
+logger = logging.getLogger(__name__)
+
+
+class ClientIDGenerator:
+ """
+ Unified Client ID Generator
+
+ Provides deterministic client_id generation algorithm, ensuring:
+ 1. Same input always produces same ID
+ 2. Different Agent/Service combinations produce different IDs
+ 3. Supports both Store and Agent modes
+ """
+
+ @staticmethod
+ def generate_deterministic_id(agent_id: str, service_name: str,
+ service_config: Dict[str, Any],
+ global_agent_store_id: str) -> str:
+ """
+ Generate deterministic client_id
+
+ Args:
+ agent_id: Agent ID
+ service_name: Service name
+ service_config: Service configuration (used to generate hash)
+ global_agent_store_id: Global Agent Store ID
+
+ Returns:
+ str: Deterministic client_id
+
+ Format description:
+ - Store service: client_store_{service_name}_{config_hash}
+ - Agent service: client_{agent_id}_{service_name}_{config_hash}
+ """
+ try:
+ # Generate configuration hash (ensure deterministic)
+ config_str = str(sorted(service_config.items())) if service_config else ""
+ config_hash = hashlib.md5(config_str.encode()).hexdigest()[:8]
+
+ # Generate different format client_id based on agent type
+ if agent_id == global_agent_store_id:
+ # Store service format
+ client_id = f"client_store_{service_name}_{config_hash}"
+ logger.debug(f" [ID_GEN] Generated Store client_id: {service_name} -> {client_id}")
+ else:
+ # Agent service format
+ client_id = f"client_{agent_id}_{service_name}_{config_hash}"
+ logger.debug(f" [ID_GEN] Generated Agent client_id: {agent_id}:{service_name} -> {client_id}")
+
+ return client_id
+
+ except Exception as e:
+ logger.error(f" [ID_GEN] Failed to generate client_id for {agent_id}:{service_name}: {e}")
+ # Fallback to simple format
+ fallback_id = f"client_{agent_id}_{service_name}_fallback"
+ logger.warning(f"[ID_GEN] [WARN] Using fallback client_id: {fallback_id}")
+ return fallback_id
+
+ @staticmethod
+ def parse_client_id(client_id: str) -> Dict[str, str]:
+ """
+ 解析client_id,提取其中的信息
+
+ Args:
+ client_id: Client ID字符串
+
+ Returns:
+ Dict: 包含解析结果的字典
+ - type: "store" 或 "agent"
+ - agent_id: Agent ID(仅Agent类型)
+ - service_name: 服务名称
+ - config_hash: 配置哈希
+ """
+ try:
+ parts = client_id.split('_')
+
+ if len(parts) >= 3 and parts[0] == "client":
+ if parts[1] == "store":
+ # Store格式: client_store_{service_name}_{hash}
+ return {
+ "type": "store",
+ "agent_id": None,
+ "service_name": parts[2],
+ "config_hash": parts[3] if len(parts) > 3 else ""
+ }
+ else:
+ # Agent格式: client_{agent_id}_{service_name}_{hash}
+ return {
+ "type": "agent",
+ "agent_id": parts[1],
+ "service_name": parts[2],
+ "config_hash": parts[3] if len(parts) > 3 else ""
+ }
+
+
+ return {
+ "type": "unknown",
+ "agent_id": None,
+ "service_name": None,
+ "config_hash": None
+ }
+
+ except Exception as e:
+ logger.error(f" [ID_GEN] Error parsing client_id {client_id}: {e}")
+ return {
+ "type": "error",
+ "agent_id": None,
+ "service_name": None,
+ "config_hash": None
+ }
+
+ @staticmethod
+ def is_deterministic_format(client_id: str) -> bool:
+ """
+ 检查client_id是否是确定性格式
+
+ Args:
+ client_id: Client ID字符串
+
+ Returns:
+ bool: 是否是确定性格式
+ """
+ try:
+ parsed = ClientIDGenerator.parse_client_id(client_id)
+ return parsed["type"] in ["store", "agent"]
+ except Exception:
+ return False
+
+
+
+def generate_id(length: int = 8) -> str:
+ """
+ 生成随机ID
+
+ Args:
+ length: ID长度,默认8位
+
+ Returns:
+ str: 随机ID字符串
+ """
+ return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
+
+
+def generate_short_id(length: int = 4) -> str:
+ """
+ 生成短随机ID
+
+ Args:
+ length: ID长度,默认4位
+
+ Returns:
+ str: 短随机ID字符串
+ """
+ return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
+
+
+def generate_uuid() -> str:
+ """
+ 生成UUID
+
+ Returns:
+ str: UUID字符串
+ """
+ return str(uuid.uuid4())
+
diff --git a/src/mcpstore/core/utils/mcp_client_helpers.py b/src/mcpstore/core/utils/mcp_client_helpers.py
new file mode 100644
index 00000000..4070a035
--- /dev/null
+++ b/src/mcpstore/core/utils/mcp_client_helpers.py
@@ -0,0 +1,40 @@
+"""
+Utility helpers for creating temporary FastMCP clients using async context managers.
+These helpers centralize config processing and ensure proper lifecycle (async with).
+"""
+
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from typing import AsyncIterator, Dict
+
+from fastmcp import Client
+
+from mcpstore.core.configuration.config_processor import ConfigProcessor
+
+
+@asynccontextmanager
+async def temp_client_for_service(service_name: str, service_config: Dict, timeout: float | None = None) -> AsyncIterator[Client]:
+ """Create a temporary FastMCP Client for a single service and yield it inside an async-with.
+
+ - Processes user service_config via ConfigProcessor to build a valid FastMCP client config
+ - Ensures the client is properly connected within an async-with block
+ - Closes the client automatically on exit
+ """
+ # Build a minimal fastmcp config for this one service
+ user_config = {"mcpServers": {service_name: service_config or {}}}
+ fastmcp_config = ConfigProcessor.process_user_config_for_fastmcp(user_config)
+
+ # If the service was removed by the processor due to validation errors, raise
+ if service_name not in fastmcp_config.get("mcpServers", {}):
+ raise ValueError(f"Invalid service configuration for {service_name}")
+
+ client = Client(fastmcp_config, timeout=timeout)
+ try:
+ async with client:
+ yield client
+ finally:
+ try:
+ await client.close()
+ except Exception:
+ pass
diff --git a/src/mcpstore/core/utils/sync_api.py b/src/mcpstore/core/utils/sync_api.py
new file mode 100644
index 00000000..49010efb
--- /dev/null
+++ b/src/mcpstore/core/utils/sync_api.py
@@ -0,0 +1,73 @@
+"""
+Unified sync wrapper utilities for bridging async methods into sync API surfaces
+without scattering run_async calls and magic flags across the codebase.
+
+Design goals:
+- Centralize timeout and background policy
+- Avoid nested event loop pitfalls
+- Keep zero behavior change for current defaults
+
+This module introduces two helpers:
+- run_sync(coro, *, timeout=None, force_background=None): thin facade over the
+ existing global helper to preserve current behavior.
+- sync_api(...): decorator for future adoption; not applied anywhere yet.
+"""
+
+import asyncio
+import functools
+import logging
+from typing import Any, Callable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+def run_sync(coro, *, timeout: Optional[float] = None, force_background: Optional[bool] = None):
+ """Run an async coroutine from sync code using asyncio.run.
+
+ 根据MCPStore核心架构原则,使用最简单的asyncio.run()来桥接同步和异步代码。
+
+ Args:
+ coro: Awaitable to execute
+ timeout: Optional timeout seconds (暂时忽略,因为asyncio.run()不支持超时)
+ force_background: Optional policy to force background loop (忽略,违反核心原则)
+
+ Returns:
+ Any: Result of the coroutine
+ """
+ if force_background:
+ logger.warning("force_background=True parameter violates core architecture principles, will be ignored")
+
+ # 简单使用asyncio.run(),符合核心原则
+ if timeout is not None:
+ logger.warning("timeout parameter is not currently supported, will be ignored")
+
+ return asyncio.run(coro)
+
+
+def sync_api(*, timeout: Optional[float] = None, force_background: Optional[bool] = None) -> Callable:
+ """Decorator to expose async implementations as sync functions with unified policy.
+
+ Usage (planned for future refactors, not applied yet):
+
+ @sync_api(timeout=60.0)
+ def list_tools(self):
+ return self._list_tools_async()
+
+ The wrapper will detect coroutine return and run via run_sync; otherwise
+ it returns the value directly, enabling gradual migration.
+ """
+
+ def decorator(func: Callable) -> Callable:
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs) -> Any:
+ result = func(*args, **kwargs)
+ # If the function returns a coroutine/awaitable, drive it
+ if hasattr(result, "__await__"):
+ return run_sync(result, timeout=timeout, force_background=force_background)
+ return result
+
+ return wrapper
+
+ return decorator
+
+
diff --git a/src/mcpstore/data/defaults/agent_clients.json b/src/mcpstore/data/defaults/agent_clients.json
deleted file mode 100644
index a28d3379..00000000
--- a/src/mcpstore/data/defaults/agent_clients.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "test_agent": [
- "client_20250616012106_c5er9r"
- ],
- "agent123": [
- "client_20250616012125_frjzcx",
- "client_20250616012152_uhkiaj"
- ]
-}
\ No newline at end of file
diff --git a/src/mcpstore/data/defaults/client_services.json b/src/mcpstore/data/defaults/client_services.json
deleted file mode 100644
index d9666168..00000000
--- a/src/mcpstore/data/defaults/client_services.json
+++ /dev/null
@@ -1,200 +0,0 @@
-{
- "client_20250616012056_wr5n5r": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c2",
- "transport": "sse"
- }
- }
- },
- "client_20250616012057_p09rb0": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616012101_r17rtm": {
- "mcpServers": {
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
- },
- "client_20250616012101_tn2g6h": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616012102_3gyizm": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616012106_c5er9r": {
- "mcpServers": {
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
- },
- "client_20250616012106_xqb64m": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616012107_6egon2": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616012110_ihszf2": {
- "mcpServers": {
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
- },
- "client_20250616012125_frjzcx": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616012127_9gvt1k": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616012127_ce5tsm": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616012131_lp0s4b": {
- "mcpServers": {
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
- },
- "client_20250616012152_uhkiaj": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616013403_kihzgj": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616013404_pq7nz9": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616013919_58i312": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616013920_h60fay": {
- "mcpServers": {
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- }
- }
- },
- "client_20250616014042_yd9c7v": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- },
- "client_20250616014437_ppsdre": {
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/mcpstore/data/mcp.json b/src/mcpstore/data/mcp.json
deleted file mode 100644
index a0703ca5..00000000
--- a/src/mcpstore/data/mcp.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- }
- }
-}
\ No newline at end of file
diff --git a/src/mcpstore/data/mcp.json.20250616_012101.bak b/src/mcpstore/data/mcp.json.20250616_012101.bak
deleted file mode 100644
index 803624ed..00000000
--- a/src/mcpstore/data/mcp.json.20250616_012101.bak
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c2",
- "transport": "sse"
- },
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- },
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
-}
diff --git a/src/mcpstore/data/mcp.json.20250616_012102.bak b/src/mcpstore/data/mcp.json.20250616_012102.bak
deleted file mode 100644
index 824b4510..00000000
--- a/src/mcpstore/data/mcp.json.20250616_012102.bak
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- },
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- },
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/mcpstore/data/mcp.json.20250616_012106.bak b/src/mcpstore/data/mcp.json.20250616_012106.bak
deleted file mode 100644
index 824b4510..00000000
--- a/src/mcpstore/data/mcp.json.20250616_012106.bak
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "mcpServers": {
- "高德": {
- "url": "https://mcp.amap.com/sse?key=da2c9c39f9edad643b9c53f506fb381c",
- "transport": "sse"
- },
- "context7": {
- "command": "npx",
- "args": [
- "-y",
- "@upstash/context7-mcp"
- ]
- },
- "新服务": {
- "command": "python",
- "args": [
- "service.py"
- ],
- "env": {
- "DEBUG": "true"
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/mcpstore/examples/usage_example.py b/src/mcpstore/examples/usage_example.py
deleted file mode 100644
index 58450ada..00000000
--- a/src/mcpstore/examples/usage_example.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""
-MCPStore 使用示例
-展示如何使用新的基于上下文的 API
-"""
-
-import asyncio
-import json
-import os
-from typing import Dict, Any, List
-
-from mcpstore import MCPStore
-from mcpstore.core.orchestrator import MCPOrchestrator
-from mcpstore.core.registry import ServiceRegistry
-from mcpstore.plugins.json_mcp import MCPConfig
-
-async def main():
- print("\n===== MCPStore 使用示例 (新版API) =====\n")
-
- # === 1. 初始化 ===
- print("1. 初始化 MCPStore")
- registry = ServiceRegistry()
- orchestrator = MCPOrchestrator({
- "timing": {
- "heartbeat_interval_seconds": 60,
- "heartbeat_timeout_seconds": 180,
- "http_timeout_seconds": 10,
- "command_timeout_seconds": 10
- }
- }, registry)
- mcp_config = MCPConfig()
- store = MCPStore(orchestrator, mcp_config)
- print(" ✓ 初始化完成")
-
- # === 2. 商店级别操作 ===
- print("\n2. 商店级别操作示例")
-
- # 2.1 使用链式调用
- print("\n2.1 链式调用方式")
- all_services = await store.for_store().list_services()
- print(f" ✓ 获取到 {len(all_services)} 个服务")
-
- await store.for_store().add_service(['weather', 'maps'])
- print(" ✓ 添加服务成功")
-
- # 2.2 保存上下文重用
- print("\n2.2 保存上下文重用")
- store_ctx = store.for_store()
- services = await store_ctx.list_services()
- tools = await store_ctx.list_tools()
- health = store_ctx.check_services()
- print(f" ✓ 商店共有 {len(services)} 个服务, {len(tools)} 个工具")
- print(f" ✓ 服务健康状态: {health}")
-
- # === 3. Agent级别操作 ===
- print("\n3. Agent级别操作示例")
-
- # 3.1 链式调用
- print("\n3.1 链式调用方式")
- agent_id = "test_agent_123"
- agent_services = await store.for_agent(agent_id).list_services()
- print(f" ✓ Agent订阅了 {len(agent_services)} 个服务")
-
- await store.for_agent(agent_id).add_service(['news'])
- print(" ✓ Agent订阅新服务成功")
-
- # 3.2 保存上下文重用
- print("\n3.2 保存上下文重用")
- agent_ctx = store.for_agent(agent_id)
- my_services = await agent_ctx.list_services()
- my_tools = await agent_ctx.list_tools()
- my_health = agent_ctx.check_services()
- print(f" ✓ Agent可用服务: {len(my_services)}")
- print(f" ✓ Agent可用工具: {len(my_tools)}")
- print(f" ✓ Agent服务健康状态: {my_health}")
-
- # === 4. 工具使用 ===
- print("\n4. 工具使用示例")
- result = await store.use_tool('get_weather', {'city': '北京'})
- print(f" ✓ 工具调用结果: {result}")
-
- print("\n===== 示例完成 =====")
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/src/mcpstore/extensions/__init__.py b/src/mcpstore/extensions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/mcpstore/extensions/monitoring/__init__.py b/src/mcpstore/extensions/monitoring/__init__.py
new file mode 100644
index 00000000..edb160ac
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/__init__.py
@@ -0,0 +1,41 @@
+"""
+MCPStore Monitoring Module
+Monitoring module
+
+Responsible for tool monitoring, performance analysis, metrics collection and monitoring configuration
+"""
+
+from .message_handler import MCPStoreMessageHandler
+# Main exports - maintain backward compatibility
+from .tools_monitor import ToolsUpdateMonitor
+
+try:
+ from .analytics import MonitoringAnalytics, EventCollector, ToolUsageMetrics, ServiceHealthMetrics
+except ImportError:
+ # If analytics module import fails, provide placeholder
+ MonitoringAnalytics = None
+ EventCollector = None
+ ToolUsageMetrics = None
+ ServiceHealthMetrics = None
+
+try:
+ from .base_monitor import MonitoringManager
+except ImportError as e:
+ print(f"Warning: Failed to import from base_monitor: {e}")
+ MonitoringManager = None
+
+try:
+ from .config import MonitoringConfig
+except ImportError:
+ MonitoringConfig = None
+
+__all__ = [
+ 'ToolsUpdateMonitor',
+ 'MCPStoreMessageHandler',
+ 'MonitoringAnalytics',
+ 'EventCollector',
+ 'ToolUsageMetrics',
+ 'ServiceHealthMetrics',
+ 'MonitoringManager',
+ 'MonitoringConfig'
+]
diff --git a/src/mcpstore/extensions/monitoring/analytics.py b/src/mcpstore/extensions/monitoring/analytics.py
new file mode 100644
index 00000000..9e5106e5
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/analytics.py
@@ -0,0 +1,448 @@
+#!/usr/bin/env python3
+"""
+Monitoring and Analytics Features
+Tool usage analysis, performance dashboard, error tracking, usage report generation
+"""
+
+import json
+import logging
+import statistics
+from collections import defaultdict, deque
+from dataclasses import dataclass, field, asdict
+from datetime import datetime, timedelta
+from enum import Enum
+from pathlib import Path
+from typing import Dict, List, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+class EventType(Enum):
+ """Event types"""
+ TOOL_EXECUTION = "tool_execution"
+ SERVICE_CONNECTION = "service_connection"
+ ERROR = "error"
+ PERFORMANCE = "performance"
+ USER_ACTION = "user_action"
+ SYSTEM = "system"
+
+class Severity(Enum):
+ """Severity levels"""
+ DEBUG = "debug"
+ INFO = "info"
+ WARNING = "warning"
+ ERROR = "error"
+ CRITICAL = "critical"
+
+@dataclass
+class Event:
+ """Event record"""
+ event_id: str
+ event_type: EventType
+ timestamp: datetime
+ severity: Severity
+ message: str
+ data: Dict[str, Any] = field(default_factory=dict)
+ user_id: Optional[str] = None
+ service_name: Optional[str] = None
+ tool_name: Optional[str] = None
+ duration: Optional[float] = None
+ success: bool = True
+
+@dataclass
+class ToolUsageMetrics:
+ """工具使用指标"""
+ tool_name: str
+ service_name: str
+ total_calls: int = 0
+ successful_calls: int = 0
+ failed_calls: int = 0
+ total_duration: float = 0.0
+ avg_duration: float = 0.0
+ min_duration: float = float('inf')
+ max_duration: float = 0.0
+ last_used: Optional[datetime] = None
+ error_rate: float = 0.0
+
+ def update(self, duration: float, success: bool):
+ """更新指标"""
+ self.total_calls += 1
+ self.total_duration += duration
+ self.last_used = datetime.now()
+
+ if success:
+ self.successful_calls += 1
+ else:
+ self.failed_calls += 1
+
+ self.avg_duration = self.total_duration / self.total_calls
+ self.min_duration = min(self.min_duration, duration)
+ self.max_duration = max(self.max_duration, duration)
+ self.error_rate = self.failed_calls / self.total_calls
+
+@dataclass
+class ServiceHealthMetrics:
+ """服务健康指标"""
+ service_name: str
+ status: str = "unknown"
+ uptime: float = 0.0
+ response_time: float = 0.0
+ error_count: int = 0
+ last_check: Optional[datetime] = None
+ connection_count: int = 0
+
+class EventCollector:
+ """事件收集器"""
+
+ def __init__(self, max_events: int = 10000):
+ self.max_events = max_events
+ self._events: deque = deque(maxlen=max_events)
+ self._event_counter = 0
+
+ def record_event(self, event: Event):
+ """Record event"""
+ event.event_id = f"evt_{self._event_counter:06d}"
+ self._event_counter += 1
+ self._events.append(event)
+
+ # Record to log
+ log_level = {
+ Severity.DEBUG: logging.DEBUG,
+ Severity.INFO: logging.INFO,
+ Severity.WARNING: logging.WARNING,
+ Severity.ERROR: logging.ERROR,
+ Severity.CRITICAL: logging.CRITICAL
+ }.get(event.severity, logging.INFO)
+
+ logger.log(log_level, f"[{event.event_type.value}] {event.message}")
+
+ def get_events(
+ self,
+ event_type: Optional[EventType] = None,
+ severity: Optional[Severity] = None,
+ since: Optional[datetime] = None,
+ limit: Optional[int] = None
+ ) -> List[Event]:
+ """Get events"""
+ events = list(self._events)
+
+ # Filter conditions
+ if event_type:
+ events = [e for e in events if e.event_type == event_type]
+
+ if severity:
+ events = [e for e in events if e.severity == severity]
+
+ if since:
+ events = [e for e in events if e.timestamp >= since]
+
+ # Sort by time in descending order
+ events.sort(key=lambda e: e.timestamp, reverse=True)
+
+ if limit:
+ events = events[:limit]
+
+ return events
+
+ def get_error_events(self, hours: int = 24) -> List[Event]:
+ """Get error events"""
+ since = datetime.now() - timedelta(hours=hours)
+ return self.get_events(
+ severity=Severity.ERROR,
+ since=since
+ )
+
+class MetricsCollector:
+ """Metrics collector"""
+
+ def __init__(self):
+ self._tool_metrics: Dict[str, ToolUsageMetrics] = {}
+ self._service_metrics: Dict[str, ServiceHealthMetrics] = {}
+ self._performance_data: Dict[str, deque] = defaultdict(lambda: deque(maxlen=1000))
+
+ def record_tool_execution(
+ self,
+ tool_name: str,
+ service_name: str,
+ duration: float,
+ success: bool,
+ user_id: Optional[str] = None
+ ):
+ """Record tool execution"""
+ key = f"{service_name}:{tool_name}"
+
+ if key not in self._tool_metrics:
+ self._tool_metrics[key] = ToolUsageMetrics(
+ tool_name=tool_name,
+ service_name=service_name
+ )
+
+ self._tool_metrics[key].update(duration, success)
+
+ # Record performance data
+ self._performance_data[key].append({
+ "timestamp": datetime.now(),
+ "duration": duration,
+ "success": success,
+ "user_id": user_id
+ })
+
+ def update_service_health(
+ self,
+ service_name: str,
+ status: str,
+ response_time: float = 0.0,
+ error_count: int = 0
+ ):
+ """Update service health status"""
+ if service_name not in self._service_metrics:
+ self._service_metrics[service_name] = ServiceHealthMetrics(
+ service_name=service_name
+ )
+
+ metrics = self._service_metrics[service_name]
+ metrics.status = status
+ metrics.response_time = response_time
+ metrics.error_count = error_count
+ metrics.last_check = datetime.now()
+
+ def get_tool_metrics(self, tool_name: Optional[str] = None) -> Dict[str, ToolUsageMetrics]:
+ """获取工具指标"""
+ if tool_name:
+ return {k: v for k, v in self._tool_metrics.items() if tool_name in k}
+ return self._tool_metrics.copy()
+
+ def get_service_health(self, service_name: Optional[str] = None) -> Dict[str, ServiceHealthMetrics]:
+ """获取服务健康状态"""
+ if service_name:
+ return {k: v for k, v in self._service_metrics.items() if k == service_name}
+ return self._service_metrics.copy()
+
+ def get_top_tools(self, limit: int = 10) -> List[ToolUsageMetrics]:
+ """获取最常用的工具"""
+ tools = list(self._tool_metrics.values())
+ tools.sort(key=lambda t: t.total_calls, reverse=True)
+ return tools[:limit]
+
+ def get_performance_trends(self, tool_name: str, hours: int = 24) -> Dict[str, Any]:
+ """获取性能趋势"""
+ key = None
+ for k in self._performance_data.keys():
+ if tool_name in k:
+ key = k
+ break
+
+ if not key:
+ return {}
+
+ data = list(self._performance_data[key])
+ since = datetime.now() - timedelta(hours=hours)
+ recent_data = [d for d in data if d["timestamp"] >= since]
+
+ if not recent_data:
+ return {}
+
+ durations = [d["duration"] for d in recent_data]
+ success_rate = sum(1 for d in recent_data if d["success"]) / len(recent_data)
+
+ return {
+ "tool_name": tool_name,
+ "period_hours": hours,
+ "total_calls": len(recent_data),
+ "success_rate": success_rate,
+ "avg_duration": statistics.mean(durations),
+ "median_duration": statistics.median(durations),
+ "min_duration": min(durations),
+ "max_duration": max(durations),
+ "std_duration": statistics.stdev(durations) if len(durations) > 1 else 0
+ }
+
+class ErrorTracker:
+ """错误追踪器"""
+
+ def __init__(self):
+ self._error_patterns: Dict[str, int] = defaultdict(int)
+ self._error_details: List[Dict[str, Any]] = []
+
+ def track_error(
+ self,
+ error: Exception,
+ context: Dict[str, Any] = None,
+ tool_name: Optional[str] = None,
+ service_name: Optional[str] = None
+ ):
+ """追踪错误"""
+ error_type = type(error).__name__
+ error_message = str(error)
+
+ # 记录错误模式
+ pattern_key = f"{error_type}:{tool_name or 'unknown'}"
+ self._error_patterns[pattern_key] += 1
+
+ # 记录错误详情
+ error_detail = {
+ "timestamp": datetime.now(),
+ "error_type": error_type,
+ "error_message": error_message,
+ "tool_name": tool_name,
+ "service_name": service_name,
+ "context": context or {},
+ "count": self._error_patterns[pattern_key]
+ }
+
+ self._error_details.append(error_detail)
+
+ # 保持最近的1000个错误
+ if len(self._error_details) > 1000:
+ self._error_details.pop(0)
+
+ def get_error_summary(self, hours: int = 24) -> Dict[str, Any]:
+ """获取错误摘要"""
+ since = datetime.now() - timedelta(hours=hours)
+ recent_errors = [
+ e for e in self._error_details
+ if e["timestamp"] >= since
+ ]
+
+ if not recent_errors:
+ return {"total_errors": 0, "error_types": {}, "top_errors": []}
+
+ # 统计错误类型
+ error_types = defaultdict(int)
+ for error in recent_errors:
+ error_types[error["error_type"]] += 1
+
+ # 获取最常见的错误
+ top_errors = sorted(
+ self._error_patterns.items(),
+ key=lambda x: x[1],
+ reverse=True
+ )[:10]
+
+ return {
+ "total_errors": len(recent_errors),
+ "error_types": dict(error_types),
+ "top_errors": [{"pattern": pattern, "count": count} for pattern, count in top_errors],
+ "recent_errors": recent_errors[-10:] # 最近10个错误
+ }
+
+class ReportGenerator:
+ """报告生成器"""
+
+ def __init__(self, metrics_collector: MetricsCollector, error_tracker: ErrorTracker):
+ self.metrics_collector = metrics_collector
+ self.error_tracker = error_tracker
+
+ def generate_usage_report(self, hours: int = 24) -> Dict[str, Any]:
+ """生成使用报告"""
+ tool_metrics = self.metrics_collector.get_tool_metrics()
+ service_health = self.metrics_collector.get_service_health()
+ top_tools = self.metrics_collector.get_top_tools()
+ error_summary = self.error_tracker.get_error_summary(hours)
+
+ return {
+ "report_period": f"{hours} hours",
+ "generated_at": datetime.now().isoformat(),
+ "summary": {
+ "total_tools": len(tool_metrics),
+ "total_services": len(service_health),
+ "total_tool_calls": sum(m.total_calls for m in tool_metrics.values()),
+ "total_errors": error_summary["total_errors"]
+ },
+ "top_tools": [asdict(tool) for tool in top_tools],
+ "service_health": {name: asdict(health) for name, health in service_health.items()},
+ "error_summary": error_summary
+ }
+
+ def save_report(self, report: Dict[str, Any], file_path: Optional[Path] = None):
+ """保存报告到文件"""
+ if not file_path:
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ file_path = Path(f"mcpstore_report_{timestamp}.json")
+
+ try:
+ with open(file_path, 'w', encoding='utf-8') as f:
+ json.dump(report, f, indent=2, ensure_ascii=False, default=str)
+ logger.info(f"Report saved to {file_path}")
+ except Exception as e:
+ logger.error(f"Failed to save report: {e}")
+
+class MonitoringManager:
+ """监控管理器"""
+
+ def __init__(self):
+ self.event_collector = EventCollector()
+ self.metrics_collector = MetricsCollector()
+ self.error_tracker = ErrorTracker()
+ self.report_generator = ReportGenerator(self.metrics_collector, self.error_tracker)
+
+ def record_tool_execution(
+ self,
+ tool_name: str,
+ service_name: str,
+ duration: float,
+ success: bool,
+ user_id: Optional[str] = None,
+ error: Optional[Exception] = None
+ ):
+ """记录工具执行"""
+ # 记录指标
+ self.metrics_collector.record_tool_execution(
+ tool_name, service_name, duration, success, user_id
+ )
+
+ # 记录事件
+ event = Event(
+ event_id="", # 将由 event_collector 分配
+ event_type=EventType.TOOL_EXECUTION,
+ timestamp=datetime.now(),
+ severity=Severity.INFO if success else Severity.ERROR,
+ message=f"Tool {tool_name} {'succeeded' if success else 'failed'}",
+ data={
+ "duration": duration,
+ "success": success
+ },
+ user_id=user_id,
+ service_name=service_name,
+ tool_name=tool_name,
+ duration=duration,
+ success=success
+ )
+ self.event_collector.record_event(event)
+
+ # 记录错误
+ if error:
+ self.error_tracker.track_error(
+ error,
+ context={"tool_name": tool_name, "service_name": service_name},
+ tool_name=tool_name,
+ service_name=service_name
+ )
+
+ def get_dashboard_data(self) -> Dict[str, Any]:
+ """获取仪表板数据"""
+ return {
+ "overview": {
+ "total_tools": len(self.metrics_collector.get_tool_metrics()),
+ "total_services": len(self.metrics_collector.get_service_health()),
+ "recent_errors": len(self.event_collector.get_error_events(hours=1))
+ },
+ "top_tools": [asdict(tool) for tool in self.metrics_collector.get_top_tools(5)],
+ "service_health": {
+ name: asdict(health)
+ for name, health in self.metrics_collector.get_service_health().items()
+ },
+ "recent_events": [
+ asdict(event) for event in self.event_collector.get_events(limit=10)
+ ],
+ "error_summary": self.error_tracker.get_error_summary(hours=24)
+ }
+
+# 全局实例
+_global_monitoring_manager = None
+
+def get_monitoring_manager() -> MonitoringManager:
+ """获取全局监控管理器"""
+ global _global_monitoring_manager
+ if _global_monitoring_manager is None:
+ _global_monitoring_manager = MonitoringManager()
+ return _global_monitoring_manager
diff --git a/src/mcpstore/extensions/monitoring/base_monitor.py b/src/mcpstore/extensions/monitoring/base_monitor.py
new file mode 100644
index 00000000..9dcd239e
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/base_monitor.py
@@ -0,0 +1,372 @@
+"""
+MCPStore monitoring and statistics module
+Provides performance monitoring, tool usage statistics, alert management and other functions
+"""
+
+import asyncio
+import json
+import logging
+import time
+from collections import deque
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional, Any
+
+logger = logging.getLogger(__name__)
+
+class MonitoringManager:
+ """监控管理器"""
+
+ def __init__(
+ self,
+ data_dir: Path,
+ tool_record_max_file_size: int = 30,
+ tool_record_retention_days: int = 7,
+ max_cache_records: int = 2000,
+ flush_interval: float = 1.0,
+ max_batch_size: int = 128,
+ ):
+ self.data_dir = data_dir
+ self.tool_records_file = data_dir / "tool_records.jsonl" # 追加写,避免全量读写
+ self.summary_file = data_dir / "tool_records_summary.json"
+
+ # 工具记录配置
+ self.max_file_size_mb = tool_record_max_file_size
+ self.retention_days = tool_record_retention_days
+ self.flush_interval = flush_interval
+ self.max_batch_size = max_batch_size
+ self.max_cache_records = max_cache_records
+
+ # 记录启动时间用于计算运行时间
+ self.start_time = time.time()
+
+ # API 监控相关
+ self.active_connections = 0
+ self.api_call_count = 0
+ self.total_response_time = 0.0
+
+ # 异步写入组件(延迟创建队列/事件,避免无事件循环时报错)
+ self._queue: Optional[asyncio.Queue] = None
+ self._stop_event: Optional[asyncio.Event] = None
+ self._worker_task: Optional[asyncio.Task] = None
+ self._enabled = True
+
+ # 内存缓存,快速返回最近记录
+ self._recent_records: deque = deque(maxlen=max_cache_records)
+ self._summary: Dict[str, Any] = self._default_summary()
+
+ self._init_storage()
+
+ def _default_summary(self) -> Dict[str, Any]:
+ return {
+ "total_executions": 0,
+ "by_tool": {},
+ "by_service": {}
+ }
+
+ def _init_storage(self) -> None:
+ """初始化文件与内存状态,失败仅告警不阻塞"""
+ try:
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ if self.summary_file.exists():
+ try:
+ self._summary = json.loads(self.summary_file.read_text(encoding="utf-8"))
+ except Exception:
+ logger.warning("[MONITORING] summary file corrupted, resetting.")
+ self._summary = self._default_summary()
+ self.summary_file.write_text(json.dumps(self._summary, indent=2, ensure_ascii=False), encoding="utf-8")
+ else:
+ self.summary_file.write_text(json.dumps(self._summary, indent=2, ensure_ascii=False), encoding="utf-8")
+
+ # 预加载有限数量的历史记录,避免大文件阻塞
+ if self.tool_records_file.exists():
+ self._load_recent_records_from_file()
+ else:
+ self.tool_records_file.touch()
+ except Exception as e:
+ logger.warning(f"[MONITORING] init storage failed, monitoring disabled: {e}")
+ self._enabled = False
+
+ def _load_recent_records_from_file(self) -> None:
+ """仅加载最后 max_cache_records 条,避免大文件带来的阻塞"""
+ try:
+ with open(self.tool_records_file, "r", encoding="utf-8") as f:
+ recent = deque((json.loads(line) for line in f if line.strip()), maxlen=self.max_cache_records)
+ self._recent_records.extend(recent)
+ except Exception as e:
+ logger.warning(f"[MONITORING] failed to load recent records: {e}")
+
+ def _ensure_primitives(self) -> bool:
+ """确保队列和停止事件存在"""
+ if not self._enabled:
+ return False
+ try:
+ if self._queue is None:
+ self._queue = asyncio.Queue()
+ if self._stop_event is None:
+ self._stop_event = asyncio.Event()
+ return True
+ except Exception as e:
+ logger.warning(f"[MONITORING] failed to init async primitives: {e}")
+ return False
+
+ # 旧的record_tool_execution方法已移除,使用record_tool_execution_detailed代替
+ # 旧的get_tool_usage_stats方法已移除,使用get_tool_records代替
+
+ def record_api_call(self, response_time: float):
+ """记录 API 调用"""
+ self.api_call_count += 1
+ self.total_response_time += response_time
+
+ def increment_active_connections(self):
+ """增加活跃连接数"""
+ self.active_connections += 1
+
+ def decrement_active_connections(self):
+ """减少活跃连接数"""
+ self.active_connections = max(0, self.active_connections - 1)
+
+ def _ensure_worker(self):
+ """确保后台写入任务已启动,不会嵌套 AOB 事件循环"""
+ if not self._enabled:
+ return
+ if self._worker_task and not self._worker_task.done():
+ return
+ if not self._ensure_primitives():
+ return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ logger.warning("[MONITORING] No running event loop; monitoring queue will start when loop is available.")
+ return
+ self._worker_task = loop.create_task(self._worker(), name="monitoring-writer")
+
+ async def stop_worker(self):
+ """停止后台任务(测试/关闭时调用)"""
+ if not self._worker_task:
+ return
+ if self._stop_event:
+ self._stop_event.set()
+ await self._worker_task
+ self._worker_task = None
+
+ async def _worker(self):
+ """后台批量落盘,避免阻塞工具调用"""
+ batch: List[Dict[str, Any]] = []
+ while self._stop_event and (not self._stop_event.is_set()):
+ try:
+ item = await asyncio.wait_for(self._queue.get(), timeout=self.flush_interval)
+ batch.append(item)
+ # 尝试一次性取满一批
+ while len(batch) < self.max_batch_size:
+ try:
+ batch.append(self._queue.get_nowait())
+ except asyncio.QueueEmpty:
+ break
+ except asyncio.TimeoutError:
+ pass
+ except Exception as e:
+ logger.warning(f"[MONITORING] worker wait error: {e}")
+
+ if not batch:
+ continue
+
+ try:
+ await asyncio.to_thread(self._flush_batch, list(batch))
+ except Exception as e:
+ logger.warning(f"[MONITORING] flush failed: {e}")
+ finally:
+ batch.clear()
+
+ def record_tool_execution_detailed(self, tool_name: str, service_name: str,
+ params: Dict[str, Any], result: Optional[Any],
+ error: Optional[str], response_time: float):
+ """异步落盘入口:仅入队,不阻塞调用链"""
+ if not self._enabled:
+ logger.warning("[MONITORING] Monitoring disabled, skip record.")
+ return
+
+ try:
+ # 如果没有事件循环且无法启动 worker,则同步落盘以避免堆积
+ loop = None
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = None
+
+ execution_time = datetime.now()
+
+ def _normalize_result(res):
+ try:
+ if hasattr(res, 'content'):
+ items = []
+ for c in getattr(res, 'content', []) or []:
+ try:
+ if isinstance(c, dict):
+ items.append(c)
+ elif hasattr(c, 'type') and hasattr(c, 'text'):
+ items.append({"type": getattr(c, 'type', 'text'), "text": getattr(c, 'text', '')})
+ elif hasattr(c, 'type') and hasattr(c, 'uri'):
+ items.append({"type": getattr(c, 'type', 'uri'), "uri": getattr(c, 'uri', '')})
+ else:
+ items.append(str(c))
+ except Exception:
+ items.append(str(c))
+ return {"content": items, "is_error": bool(getattr(res, 'is_error', False))}
+ if isinstance(res, (dict, list)):
+ return res
+ return {"result": str(res)}
+ except Exception:
+ return {"result": str(res)}
+
+ record = {
+ "id": f"{int(execution_time.timestamp() * 1000)}_{hash(tool_name) % 10000:04d}",
+ "tool_name": tool_name,
+ "service_name": service_name,
+ "params": params,
+ "result": _normalize_result(result),
+ "error": error,
+ "response_time": round(response_time, 2),
+ "execution_time": execution_time.isoformat(),
+ "timestamp": int(execution_time.timestamp())
+ }
+
+ self._recent_records.append(record)
+ self._update_summary_in_memory(record)
+
+ if loop is None:
+ # 无事件循环:直接同步写,失败不抛出
+ try:
+ self._flush_batch([record])
+ except Exception as flush_error:
+ logger.warning(f"[MONITORING] sync flush failed (no event loop): {flush_error}")
+ return
+
+ try:
+ if self._ensure_primitives() and self._queue:
+ self._queue.put_nowait(record)
+ self._ensure_worker()
+ except Exception as queue_error:
+ logger.warning(f"[MONITORING] enqueue failed: {queue_error}")
+ except Exception as e:
+ logger.warning(f"[MONITORING] Failed to prepare monitoring record: {e}")
+
+ def _update_summary_in_memory(self, record: Dict[str, Any]) -> None:
+ """增量更新汇总统计"""
+ summary = self._summary
+ tool_name = record.get("tool_name", "unknown")
+ service_name = record.get("service_name", "unknown")
+ response_time = record.get("response_time", 0.0)
+
+ summary["total_executions"] += 1
+
+ tool_stats = summary["by_tool"].setdefault(tool_name, {"count": 0, "total_response_time": 0.0})
+ tool_stats["count"] += 1
+ tool_stats["total_response_time"] += response_time
+ tool_stats["avg_response_time"] = round(tool_stats["total_response_time"] / tool_stats["count"], 2)
+
+ service_stats = summary["by_service"].setdefault(service_name, {"count": 0, "total_response_time": 0.0})
+ service_stats["count"] += 1
+ service_stats["total_response_time"] += response_time
+ service_stats["avg_response_time"] = round(service_stats["total_response_time"] / service_stats["count"], 2)
+
+ def _flush_batch(self, batch: List[Dict[str, Any]]) -> None:
+ """写入文件(在线程中执行),尽量减少阻塞"""
+ try:
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ except Exception as e:
+ logger.warning(f"[MONITORING] failed to ensure data dir: {e}")
+ return
+
+ try:
+ with open(self.tool_records_file, "a", encoding="utf-8") as f:
+ for record in batch:
+ f.write(json.dumps(record, ensure_ascii=False))
+ f.write("\n")
+ except Exception as e:
+ logger.warning(f"[MONITORING] write file failed: {e}")
+ return
+
+ # 写入汇总文件(小文件,不易阻塞)
+ try:
+ self.summary_file.write_text(json.dumps(self._summary, indent=2, ensure_ascii=False), encoding="utf-8")
+ except Exception as e:
+ logger.warning(f"[MONITORING] write summary failed: {e}")
+
+ self._maybe_rotate()
+
+ def _maybe_rotate(self) -> None:
+ """简单的文件大小保护:超限时轮转"""
+ if self.max_file_size_mb == -1:
+ return
+ try:
+ size_mb = self.tool_records_file.stat().st_size / (1024 * 1024)
+ if size_mb > self.max_file_size_mb:
+ rotated = self.data_dir / f"tool_records_{int(time.time())}.bak"
+ try:
+ self.tool_records_file.rename(rotated)
+ logger.warning(f"[MONITORING] tool_records file rotated to {rotated}")
+ except Exception as e:
+ logger.warning(f"[MONITORING] rotate failed: {e}")
+ finally:
+ # 新文件,保留最近缓存中的记录,以便后续继续追加
+ self.tool_records_file.touch()
+ # 清理过期的备份文件(按天数)
+ if self.retention_days != -1:
+ cutoff_ts = time.time() - self.retention_days * 86400
+ for bak in self.data_dir.glob("tool_records_*.bak"):
+ try:
+ if bak.stat().st_mtime < cutoff_ts:
+ bak.unlink()
+ except Exception:
+ continue
+ except Exception as e:
+ logger.debug(f"[MONITORING] rotate check failed: {e}")
+
+ def get_tool_records(self, limit: int = 50) -> Dict[str, Any]:
+ """获取工具执行记录(仅从内存缓存返回,避免大文件阻塞)"""
+ if not self._enabled:
+ return {
+ "executions": [],
+ "summary": self._default_summary(),
+ "warning": "Monitoring disabled"
+ }
+ try:
+ executions = list(self._recent_records)
+
+ # 按保留天数过滤(仅返回时过滤,不阻塞调用)
+ if self.retention_days != -1:
+ cutoff_ts = int(time.time() - self.retention_days * 86400)
+ executions = [e for e in executions if e.get("timestamp", 0) >= cutoff_ts]
+
+ executions.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
+ if limit > 0:
+ executions = executions[:limit]
+
+ # 返回时重建简易 summary(避免使用过期缓存)
+ summary = self._default_summary()
+ for ex in executions:
+ tool = ex.get("tool_name", "unknown")
+ svc = ex.get("service_name", "unknown")
+ rt = ex.get("response_time", 0.0)
+ summary["total_executions"] += 1
+ ts = summary["by_tool"].setdefault(tool, {"count": 0, "total_response_time": 0.0})
+ ts["count"] += 1
+ ts["total_response_time"] += rt
+ ts["avg_response_time"] = round(ts["total_response_time"] / ts["count"], 2)
+ ss = summary["by_service"].setdefault(svc, {"count": 0, "total_response_time": 0.0})
+ ss["count"] += 1
+ ss["total_response_time"] += rt
+ ss["avg_response_time"] = round(ss["total_response_time"] / ss["count"], 2)
+
+ return {
+ "executions": executions,
+ "summary": summary
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to get tool records: {e}")
+ return {
+ "executions": [],
+ "summary": self._default_summary()
+ }
diff --git a/src/mcpstore/extensions/monitoring/config.py b/src/mcpstore/extensions/monitoring/config.py
new file mode 100644
index 00000000..d61b9410
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/config.py
@@ -0,0 +1,255 @@
+"""
+统一监控配置管理器
+处理用户监控配置,提供默认值和配置验证
+现在从 MCPStoreConfig 获取配置值
+"""
+
+import logging
+from typing import Dict, Any, Optional
+
+from mcpstore.config.config_defaults import MonitoringConfigDefaults
+
+logger = logging.getLogger(__name__)
+
+_monitoring_defaults = MonitoringConfigDefaults()
+
+
+class MonitoringConfigProcessor:
+ """监控配置处理器"""
+
+ @classmethod
+ def get_config_from_mcpstore(cls) -> Dict[str, Any]:
+ """
+ 从 MCPStoreConfig 获取监控配置
+
+ Returns:
+ 从 MCPStoreConfig 读取的监控配置字典,如果 MCPStoreConfig 未初始化则返回默认配置
+ """
+ try:
+ from mcpstore.config.toml_config import get_monitoring_config_with_defaults
+ config = get_monitoring_config_with_defaults()
+
+ # 将 dataclass 转换为字典格式以保持向后兼容
+ if hasattr(config, '__dict__'):
+ return {
+ "health_check_seconds": getattr(config, 'health_check_seconds', _monitoring_defaults.health_check_seconds),
+ "tools_update_hours": getattr(config, 'tools_update_hours', _monitoring_defaults.tools_update_hours),
+ "reconnection_seconds": getattr(config, 'reconnection_seconds', _monitoring_defaults.reconnection_seconds),
+ "cleanup_hours": getattr(config, 'cleanup_hours', _monitoring_defaults.cleanup_hours),
+ "enable_tools_update": getattr(config, 'enable_tools_update', _monitoring_defaults.enable_tools_update),
+ "enable_reconnection": getattr(config, 'enable_reconnection', _monitoring_defaults.enable_reconnection),
+ "update_tools_on_reconnection": getattr(config, 'update_tools_on_reconnection', _monitoring_defaults.update_tools_on_reconnection),
+ "detect_tools_changes": getattr(config, 'detect_tools_changes', _monitoring_defaults.detect_tools_changes),
+ "local_service_ping_timeout": getattr(config, 'local_service_ping_timeout', _monitoring_defaults.local_service_ping_timeout),
+ "remote_service_ping_timeout": getattr(config, 'remote_service_ping_timeout', _monitoring_defaults.remote_service_ping_timeout),
+ "startup_wait_time": getattr(config, 'startup_wait_time', _monitoring_defaults.startup_wait_time),
+ "healthy_response_threshold": getattr(config, 'healthy_response_threshold', _monitoring_defaults.healthy_response_threshold),
+ "warning_response_threshold": getattr(config, 'warning_response_threshold', _monitoring_defaults.warning_response_threshold),
+ "slow_response_threshold": getattr(config, 'slow_response_threshold', _monitoring_defaults.slow_response_threshold),
+ "enable_adaptive_timeout": getattr(config, 'enable_adaptive_timeout', _monitoring_defaults.enable_adaptive_timeout),
+ "adaptive_timeout_multiplier": getattr(config, 'adaptive_timeout_multiplier', _monitoring_defaults.adaptive_timeout_multiplier),
+ "response_time_history_size": getattr(config, 'response_time_history_size', _monitoring_defaults.response_time_history_size),
+ }
+ else:
+ # 如果返回的是字典,直接使用
+ return config
+
+ except Exception as e:
+ logger.warning(f"Failed to get monitoring config from MCPStoreConfig: {e}, using defaults")
+ # 返回默认配置作为回退
+ return cls._get_default_config()
+
+ @classmethod
+ def _get_default_config(cls) -> Dict[str, Any]:
+ """获取默认监控配置(回退配置)"""
+ return {
+ "health_check_seconds": _monitoring_defaults.health_check_seconds, # 30秒健康检查
+ "tools_update_hours": _monitoring_defaults.tools_update_hours, # 2小时工具更新检查
+ "reconnection_seconds": _monitoring_defaults.reconnection_seconds, # 1分钟重连间隔
+ "cleanup_hours": _monitoring_defaults.cleanup_hours, # 24小时清理一次
+ "enable_tools_update": _monitoring_defaults.enable_tools_update, # 启用工具更新
+ "enable_reconnection": _monitoring_defaults.enable_reconnection, # 启用重连
+ "update_tools_on_reconnection": _monitoring_defaults.update_tools_on_reconnection, # 重连时更新工具
+ "detect_tools_changes": _monitoring_defaults.detect_tools_changes, # 关闭智能变化检测(避免额外开销)
+
+ # 健康检查相关
+ "local_service_ping_timeout": _monitoring_defaults.local_service_ping_timeout, # 本地服务ping超时
+ "remote_service_ping_timeout": _monitoring_defaults.remote_service_ping_timeout, # 远程服务ping超时
+ "startup_wait_time": _monitoring_defaults.startup_wait_time, # 启动等待时间
+ "healthy_response_threshold": _monitoring_defaults.healthy_response_threshold, # 健康响应阈值
+ "warning_response_threshold": _monitoring_defaults.warning_response_threshold, # 警告响应阈值
+ "slow_response_threshold": _monitoring_defaults.slow_response_threshold, # 慢响应阈值
+ "enable_adaptive_timeout": _monitoring_defaults.enable_adaptive_timeout, # 启用智能超时
+ "adaptive_timeout_multiplier": _monitoring_defaults.adaptive_timeout_multiplier, # 智能超时倍数
+ "response_time_history_size": _monitoring_defaults.response_time_history_size # 响应时间历史大小
+ }
+
+ # 配置验证规则
+ VALIDATION_RULES = {
+ "health_check_seconds": {"min": 10, "max": 300},
+ "tools_update_hours": {"min": 0.1, "max": 168}, # 6分钟到7天
+ "reconnection_seconds": {"min": 10, "max": 600},
+ "cleanup_hours": {"min": 1, "max": 168},
+ "local_service_ping_timeout": {"min": 1, "max": 30},
+ "remote_service_ping_timeout": {"min": 1, "max": 60},
+ "startup_wait_time": {"min": 0, "max": 30},
+ "healthy_response_threshold": {"min": 0.1, "max": 10.0},
+ "warning_response_threshold": {"min": 0.5, "max": 30.0},
+ "slow_response_threshold": {"min": 1.0, "max": 120.0},
+ "adaptive_timeout_multiplier": {"min": 1.0, "max": 5.0},
+ "response_time_history_size": {"min": 5, "max": 100}
+ }
+
+ @classmethod
+ def process_config(cls, user_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """
+ 处理用户监控配置
+
+ Args:
+ user_config: 用户提供的监控配置(可选覆盖配置)
+
+ Returns:
+ 完整的监控配置(基于 MCPStoreConfig + 用户覆盖)
+ """
+ if user_config is None:
+ user_config = {}
+
+ # 从 MCPStoreConfig 获取基础配置
+ final_config = cls.get_config_from_mcpstore()
+
+ # 应用用户配置覆盖
+ for key, value in user_config.items():
+ if key in final_config:
+ # 验证配置值
+ if cls._validate_config_value(key, value):
+ final_config[key] = value
+ logger.info(f"Applied user override for monitoring config: {key} = {value}")
+ else:
+ logger.warning(f"Invalid monitoring config value for {key}: {value}, using MCPStoreConfig value: {final_config[key]}")
+ else:
+ logger.warning(f"Unknown monitoring config key: {key}, ignoring")
+
+ # 配置一致性检查
+ final_config = cls._ensure_config_consistency(final_config)
+
+ logger.info(f"Monitoring configuration processed: {cls._get_config_summary(final_config)}")
+ return final_config
+
+ @classmethod
+ def _validate_config_value(cls, key: str, value: Any) -> bool:
+ """验证配置值"""
+ try:
+ # 布尔值配置
+ if key.startswith("enable_") or key.startswith("update_") or key.startswith("detect_"):
+ return isinstance(value, bool)
+
+ # 数值配置
+ if key in cls.VALIDATION_RULES:
+ if not isinstance(value, (int, float)):
+ return False
+
+ rules = cls.VALIDATION_RULES[key]
+ return rules["min"] <= value <= rules["max"]
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Error validating config {key}={value}: {e}")
+ return False
+
+ @classmethod
+ def _ensure_config_consistency(cls, config: Dict[str, Any]) -> Dict[str, Any]:
+ """确保配置一致性"""
+ # 确保响应阈值的逻辑顺序
+ if config["warning_response_threshold"] <= config["healthy_response_threshold"]:
+ config["warning_response_threshold"] = config["healthy_response_threshold"] + 1.0
+ logger.warning("Adjusted warning_response_threshold to maintain logical order")
+
+ if config["slow_response_threshold"] <= config["warning_response_threshold"]:
+ config["slow_response_threshold"] = config["warning_response_threshold"] + 2.0
+ logger.warning("Adjusted slow_response_threshold to maintain logical order")
+
+ # 如果禁用工具更新,相关配置无效
+ if not config["enable_tools_update"]:
+ config["update_tools_on_reconnection"] = False
+ config["detect_tools_changes"] = False
+
+ return config
+
+ @classmethod
+ def _get_config_summary(cls, config: Dict[str, Any]) -> str:
+ """获取配置摘要"""
+ return (f"health_check={config['health_check_seconds']}s, "
+ f"tools_update={config['tools_update_hours']}h, "
+ f"reconnection={config['reconnection_seconds']}s, "
+ f"tools_update_enabled={config['enable_tools_update']}")
+
+ @classmethod
+ def convert_to_orchestrator_config(cls, monitoring_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ 将监控配置转换为Orchestrator配置格式
+
+ Args:
+ monitoring_config: 处理后的监控配置
+
+ Returns:
+ Orchestrator兼容的配置
+ """
+ return {
+ "timing": {
+ # 心跳和重连配置
+ "heartbeat_interval_seconds": monitoring_config["health_check_seconds"],
+ "reconnection_interval_seconds": monitoring_config["reconnection_seconds"],
+ "cleanup_interval_seconds": monitoring_config["cleanup_hours"] * 3600,
+
+ # 工具更新配置
+ "tools_update_interval_seconds": monitoring_config["tools_update_hours"] * 3600,
+ "enable_tools_update": monitoring_config["enable_tools_update"],
+ "update_tools_on_reconnection": monitoring_config["update_tools_on_reconnection"],
+ "detect_tools_changes": monitoring_config["detect_tools_changes"],
+
+ # 健康检查配置
+ "local_service_ping_timeout": monitoring_config["local_service_ping_timeout"],
+ "remote_service_ping_timeout": monitoring_config["remote_service_ping_timeout"],
+ "startup_wait_time": monitoring_config["startup_wait_time"],
+ "healthy_response_threshold": monitoring_config["healthy_response_threshold"],
+ "warning_response_threshold": monitoring_config["warning_response_threshold"],
+ "slow_response_threshold": monitoring_config["slow_response_threshold"],
+ "enable_adaptive_timeout": monitoring_config["enable_adaptive_timeout"],
+ "adaptive_timeout_multiplier": monitoring_config["adaptive_timeout_multiplier"],
+ "response_time_history_size": monitoring_config["response_time_history_size"],
+
+ # HTTP超时
+ "http_timeout_seconds": max(
+ monitoring_config["local_service_ping_timeout"],
+ monitoring_config["remote_service_ping_timeout"]
+ )
+ }
+ }
+
+ @classmethod
+ def get_default_config(cls) -> Dict[str, Any]:
+ """获取默认配置(现在从 MCPStoreConfig 获取)"""
+ return cls.get_config_from_mcpstore()
+
+ @classmethod
+ def validate_user_config(cls, user_config: Dict[str, Any]) -> tuple[bool, list[str]]:
+ """
+ 验证用户配置
+
+ Returns:
+ (是否有效, 错误信息列表)
+ """
+ errors = []
+
+ # 获取当前有效配置作为参考
+ valid_config = cls.get_config_from_mcpstore()
+
+ for key, value in user_config.items():
+ if key not in valid_config:
+ errors.append(f"Unknown config key: {key}")
+ elif not cls._validate_config_value(key, value):
+ rules = cls.VALIDATION_RULES.get(key, {})
+ errors.append(f"Invalid value for {key}: {value} (expected: {rules})")
+
+ return len(errors) == 0, errors
diff --git a/src/mcpstore/extensions/monitoring/message_handler.py b/src/mcpstore/extensions/monitoring/message_handler.py
new file mode 100644
index 00000000..7f4dd446
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/message_handler.py
@@ -0,0 +1,189 @@
+"""
+FastMCP Message Handler
+Handles notification messages from FastMCP servers
+"""
+
+import logging
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+# Check FastMCP availability
+try:
+ import mcp.types
+ FASTMCP_AVAILABLE = True
+ logger.debug("FastMCP is available for notification handling")
+except ImportError:
+ logger.warning("FastMCP not available, notification features will be disabled")
+ FASTMCP_AVAILABLE = False
+
+
+class MCPStoreMessageHandler:
+ """MCPStore-specific FastMCP message handler"""
+
+ def __init__(self, tools_monitor):
+ """
+ Initialize message handler
+
+ Args:
+ tools_monitor: ToolsUpdateMonitor instance
+ """
+ if not FASTMCP_AVAILABLE:
+ logger.warning("FastMCP not available, notification features disabled")
+ return
+
+ self.tools_monitor = tools_monitor
+ self.notification_history = []
+ self.max_history = 100
+
+ async def on_tool_list_changed(self, notification: 'mcp.types.ToolListChangedNotification') -> None:
+ """Handle tool list change notifications"""
+ if not FASTMCP_AVAILABLE:
+ return
+
+ logger.info("Received tools/list_changed notification from FastMCP server")
+
+ # Record notification history
+ self._record_notification("tools_changed", notification)
+
+ # Trigger immediate update
+ try:
+ await self.tools_monitor.handle_notification_trigger("tools_changed")
+ except Exception as e:
+ logger.error(f"Error handling tools/list_changed notification: {e}")
+
+ async def on_resource_list_changed(self, notification: 'mcp.types.ResourceListChangedNotification') -> None:
+ """处理资源列表变更通知"""
+ if not FASTMCP_AVAILABLE:
+ return
+
+ logger.info("Received resources/list_changed notification from FastMCP server")
+
+ # 记录通知历史
+ self._record_notification("resources_changed", notification)
+
+ # TODO: 触发资源更新 - 后续版本实现
+ # 当前版本仅记录通知,不触发实际更新
+ try:
+ # await self.tools_monitor.handle_notification_trigger("resources_changed")
+ logger.debug("Resources notification received but update not implemented yet")
+ except Exception as e:
+ logger.error(f"Error handling resources/list_changed notification: {e}")
+
+ async def on_prompt_list_changed(self, notification: 'mcp.types.PromptListChangedNotification') -> None:
+ """处理提示词列表变更通知"""
+ if not FASTMCP_AVAILABLE:
+ return
+
+ logger.info("Received prompts/list_changed notification from FastMCP server")
+
+ # 记录通知历史
+ self._record_notification("prompts_changed", notification)
+
+ # TODO: 触发提示词更新 - 后续版本实现
+ # 当前版本仅记录通知,不触发实际更新
+ try:
+ # await self.tools_monitor.handle_notification_trigger("prompts_changed")
+ logger.debug("Prompts notification received but update not implemented yet")
+ except Exception as e:
+ logger.error(f"Error handling prompts/list_changed notification: {e}")
+
+ def _record_notification(self, notification_type: str, notification: Any):
+ """记录通知历史"""
+ if not FASTMCP_AVAILABLE:
+ return
+
+ record = {
+ "type": notification_type,
+ "timestamp": datetime.now().isoformat(),
+ "notification": notification
+ }
+
+ self.notification_history.append(record)
+
+ # 保持历史记录在限制范围内
+ if len(self.notification_history) > self.max_history:
+ self.notification_history = self.notification_history[-self.max_history:]
+
+ logger.debug(f"Recorded {notification_type} notification, history size: {len(self.notification_history)}")
+
+ def get_notification_history(self, notification_type: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
+ """
+ 获取通知历史
+
+ Args:
+ notification_type: 通知类型过滤器,None表示所有类型
+ limit: 返回记录数限制
+
+ Returns:
+ List[Dict]: 通知历史记录
+ """
+ if not FASTMCP_AVAILABLE:
+ return []
+
+ history = self.notification_history
+
+ # 按类型过滤
+ if notification_type:
+ history = [record for record in history if record["type"] == notification_type]
+
+ # 按时间倒序排列并限制数量
+ history = sorted(history, key=lambda x: x["timestamp"], reverse=True)
+ return history[:limit]
+
+ def clear_notification_history(self, notification_type: Optional[str] = None):
+ """
+ 清理通知历史
+
+ Args:
+ notification_type: 要清理的通知类型,None表示清理所有
+ """
+ if not FASTMCP_AVAILABLE:
+ return
+
+ if notification_type:
+ self.notification_history = [
+ record for record in self.notification_history
+ if record["type"] != notification_type
+ ]
+ logger.debug(f"Cleared {notification_type} notification history")
+ else:
+ self.notification_history.clear()
+ logger.debug("Cleared all notification history")
+
+ def get_notification_stats(self) -> Dict[str, Any]:
+ """
+ 获取通知统计信息
+
+ Returns:
+ Dict: 统计信息
+ """
+ if not FASTMCP_AVAILABLE:
+ return {"fastmcp_available": False}
+
+ stats = {
+ "fastmcp_available": True,
+ "total_notifications": len(self.notification_history),
+ "by_type": {},
+ "recent_activity": []
+ }
+
+ # 按类型统计
+ for record in self.notification_history:
+ notification_type = record["type"]
+ if notification_type not in stats["by_type"]:
+ stats["by_type"][notification_type] = 0
+ stats["by_type"][notification_type] += 1
+
+ # 最近活动(最近10条)
+ recent = sorted(self.notification_history, key=lambda x: x["timestamp"], reverse=True)[:10]
+ stats["recent_activity"] = [
+ {
+ "type": record["type"],
+ "timestamp": record["timestamp"]
+ }
+ for record in recent
+ ]
+
+ return stats
diff --git a/src/mcpstore/extensions/monitoring/tools_monitor.py b/src/mcpstore/extensions/monitoring/tools_monitor.py
new file mode 100644
index 00000000..3c90645e
--- /dev/null
+++ b/src/mcpstore/extensions/monitoring/tools_monitor.py
@@ -0,0 +1,490 @@
+"""
+Tool Update Monitor
+Supports FastMCP notification mechanism + polling backup strategy
+"""
+
+import asyncio
+import logging
+import time
+from datetime import datetime
+from typing import Dict, Optional, Any
+
+from mcpstore.core.utils.mcp_client_helpers import temp_client_for_service
+from .message_handler import MCPStoreMessageHandler, FASTMCP_AVAILABLE
+
+logger = logging.getLogger(__name__)
+
+
+class ToolsUpdateMonitor:
+ """
+ Hybrid tool list update monitor
+ Supports FastMCP notification mechanism + polling backup strategy
+ """
+
+ def __init__(self, orchestrator):
+ self.orchestrator = orchestrator
+ self.registry = orchestrator.registry
+
+ # Configuration parameters (obtained from orchestrator configuration)
+ timing_config = orchestrator.config.get("timing", {})
+ self.tools_update_interval = timing_config.get("tools_update_interval_seconds", 7200) # Default 2 hours
+ self.enable_tools_update = timing_config.get("enable_tools_update", True)
+ self.update_tools_on_reconnection = timing_config.get("update_tools_on_reconnection", True)
+ self.detect_tools_changes = timing_config.get("detect_tools_changes", False)
+
+ # New: notification-related configuration
+ notification_config = orchestrator.config.get("notifications", {})
+ self.enable_notifications = notification_config.get("enable_notifications", True) and FASTMCP_AVAILABLE
+ self.notification_debounce_seconds = notification_config.get("debounce_seconds", 5)
+ self.notification_timeout_seconds = notification_config.get("timeout_seconds", 30)
+ self.fallback_to_polling = notification_config.get("fallback_to_polling", True)
+
+ # Status tracking
+ self.last_update_times: Dict[str, float] = {} # service_name -> timestamp
+ self.last_notification_times: Dict[str, float] = {} # Notification debouncing
+ self.update_task: Optional[asyncio.Task] = None
+ self.is_running = False
+
+ # FastMCP message handler
+ self.message_handler = None
+ if self.enable_notifications:
+ self.message_handler = MCPStoreMessageHandler(self)
+
+
+
+ def _update_service_timestamp(self, service_name: str, client_id: str):
+ """更新服务的时间戳(统一方法)"""
+ service_key = f"{client_id}:{service_name}"
+ self.last_update_times[service_key] = time.time()
+
+ def get_message_handler(self):
+ """获取FastMCP消息处理器"""
+ return self.message_handler
+
+ async def handle_notification_trigger(self, notification_type: str) -> Dict[str, Any]:
+ """
+ 处理通知触发的工具更新
+
+ Args:
+ notification_type: 通知类型 ("tools_changed", "resources_changed", etc.)
+
+ Returns:
+ Dict: 更新结果
+ """
+ if not self.enable_notifications:
+ logger.debug("[TOOLS_MONITOR] notification disabled ignore")
+ return {"changed": False, "trigger": "notification", "reason": "disabled"}
+
+ # 防抖处理
+ current_time = time.time()
+ last_notification = self.last_notification_times.get(notification_type, 0)
+
+ if current_time - last_notification < self.notification_debounce_seconds:
+ logger.debug(f"Notification debounced for {notification_type}")
+ return {"changed": False, "trigger": "notification", "reason": "debounced"}
+
+ self.last_notification_times[notification_type] = current_time
+
+ logger.debug(f"Tools monitor notification trigger: {notification_type}")
+
+ try:
+ # 执行立即更新
+ result = await self.trigger_immediate_update()
+ result["trigger"] = "notification"
+ result["notification_type"] = notification_type
+
+ logger.debug(f"Tools monitor update completed: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"[TOOLS_MONITOR] notification error={e}")
+ return {
+ "changed": False,
+ "trigger": "notification",
+ "notification_type": notification_type,
+ "error": str(e)
+ }
+
+ async def start(self):
+ """启动工具更新监控"""
+ if not self.enable_tools_update:
+ logger.debug("Tools update monitoring is disabled")
+ return
+
+ if self.is_running:
+ logger.warning("ToolsUpdateMonitor is already running")
+ return
+
+ self.is_running = True
+
+ try:
+ loop = asyncio.get_running_loop()
+ self.update_task = loop.create_task(self._update_loop())
+ self.update_task.add_done_callback(self._task_done_callback)
+ logger.info("ToolsUpdateMonitor started")
+ except Exception as e:
+ self.is_running = False
+ logger.error(f"Failed to start ToolsUpdateMonitor: {e}")
+ raise
+
+ async def stop(self):
+ """停止工具更新监控"""
+ self.is_running = False
+
+ if self.update_task and not self.update_task.done():
+ logger.debug("Cancelling tools update task...")
+ self.update_task.cancel()
+ try:
+ await self.update_task
+ except asyncio.CancelledError:
+ logger.debug("Tools update task was cancelled")
+ except Exception as e:
+ logger.error(f"Error during tools update task cancellation: {e}")
+
+ logger.info("ToolsUpdateMonitor stopped")
+
+ def _task_done_callback(self, task):
+ """更新任务完成回调"""
+ if task.cancelled():
+ logger.info("Tools update task was cancelled")
+ elif task.exception():
+ logger.error(f"Tools update task failed: {task.exception()}")
+ else:
+ logger.info("Tools update task completed normally")
+
+ self.is_running = False
+
+ async def _update_loop(self):
+ """工具更新主循环"""
+ logger.info("Starting tools update loop")
+
+ while self.is_running:
+ try:
+ # 执行定期更新
+ await self._perform_scheduled_update()
+
+ # 等待下一次更新
+ await asyncio.sleep(self.tools_update_interval)
+
+ except asyncio.CancelledError:
+ logger.info("Tools update loop was cancelled")
+ break
+ except Exception as e:
+ logger.error(f" Error in tools update loop: {e}")
+ # 继续运行,不要因为单次错误而停止整个循环
+ await asyncio.sleep(60) # 错误后等待1分钟再继续
+
+ logger.info("Tools update loop ended")
+
+ async def _perform_scheduled_update(self):
+ """执行定期更新"""
+ if not self.enable_tools_update:
+ return
+
+ logger.debug("[TOOLS_MONITOR] scheduled_update start")
+
+ try:
+ result = await self.trigger_immediate_update()
+ result["trigger"] = "scheduled"
+
+ if result.get("changed", False):
+ logger.info(f"[TOOLS_MONITOR] scheduled_update changes result={result}")
+ else:
+ logger.debug(f"[TOOLS_MONITOR] scheduled_update no_changes result={result}")
+
+ except Exception as e:
+ logger.error(f" Error during scheduled update: {e}")
+
+ async def trigger_immediate_update(self) -> Dict[str, Any]:
+ """
+ 触发立即更新所有服务的工具列表
+
+ Returns:
+ Dict: 更新结果摘要
+ """
+ if not self.enable_tools_update:
+ return {"changed": False, "reason": "disabled"}
+
+ logger.debug("[TOOLS_MONITOR] immediate_update start")
+ start_time = time.time()
+
+ # 获取所有活跃的服务
+ all_services = []
+ for client_id in self.registry.sessions:
+ for service_name in self.registry.sessions[client_id]:
+ all_services.append((client_id, service_name))
+
+ if not all_services:
+ logger.debug("[TOOLS_MONITOR] no_active_services")
+ return {
+ "changed": False,
+ "reason": "no_services",
+ "duration": time.time() - start_time,
+ "timestamp": datetime.now().isoformat()
+ }
+
+ logger.debug(f"Found {len(all_services)} services to update")
+
+ # 并发更新所有服务
+ update_tasks = []
+ for client_id, service_name in all_services:
+ task = asyncio.create_task(
+ self._update_service_tools(client_id, service_name)
+ )
+ update_tasks.append(task)
+
+ # 等待所有更新完成
+ results = await asyncio.gather(*update_tasks, return_exceptions=True)
+
+ # 分析结果
+ total_services = len(all_services)
+ successful_updates = 0
+ failed_updates = 0
+ services_with_changes = 0
+ total_changes = 0
+
+ for i, result in enumerate(results):
+ client_id, service_name = all_services[i]
+
+ if isinstance(result, Exception):
+ failed_updates += 1
+ logger.error(f"[TOOLS_MONITOR] update_failed service='{service_name}' client='{client_id}' error={result}")
+ elif isinstance(result, dict):
+ successful_updates += 1
+ if result.get("changed", False):
+ services_with_changes += 1
+ total_changes += result.get("changes_count", 0)
+ logger.info(f"[TOOLS_MONITOR] updated service='{service_name}' client='{client_id}' changes={result.get('changes_count', 0)}")
+ else:
+ logger.debug(f"[TOOLS_MONITOR] no_changes service='{service_name}' client='{client_id}'")
+ else:
+ failed_updates += 1
+ logger.error(f"[TOOLS_MONITOR] unexpected_result_type service='{service_name}' client='{client_id}' type={type(result)}")
+
+ duration = time.time() - start_time
+
+ summary = {
+ "changed": services_with_changes > 0,
+ "total_services": total_services,
+ "successful_updates": successful_updates,
+ "failed_updates": failed_updates,
+ "services_with_changes": services_with_changes,
+ "total_changes": total_changes,
+ "duration": duration,
+ "timestamp": datetime.now().isoformat()
+ }
+
+ logger.info(f"[TOOLS_MONITOR] immediate_update done summary={summary}")
+ return summary
+
+ async def _update_service_tools(self, client_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 更新单个服务的工具列表
+
+ Args:
+ client_id: 客户端ID
+ service_name: 服务名称
+
+ Returns:
+ Dict: 更新结果
+ """
+ try:
+ logger.debug(f"[TOOLS_MONITOR] updating service='{service_name}' client='{client_id}'")
+
+ # 获取服务配置(使用缓存配置创建临时客户端)
+ service_config = self.registry.get_service_config_from_cache(client_id, service_name)
+ if not service_config:
+ return {
+ "changed": False,
+ "error": f"No service config found for {service_name}",
+ "service_name": service_name,
+ "client_id": client_id
+ }
+
+ # 获取当前工具列表(用于变更统计)
+ old_tools = set(self.registry.get_tools_for_service(client_id, service_name))
+
+ # 从服务获取最新工具列表(使用临时 client)
+ try:
+ async with temp_client_for_service(service_name, service_config) as client:
+ tools_response = await client.list_tools()
+ new_tools = {getattr(t, 'name', None) or (t.get('name') if hasattr(t, 'get') else None) for t in tools_response}
+ new_tools = {n for n in new_tools if n}
+ except Exception as e:
+ logger.error(f"[TOOLS_MONITOR] list_tools_failed service='{service_name}' error={e}")
+ return {
+ "changed": False,
+ "error": f"Failed to list tools: {str(e)}",
+ "service_name": service_name,
+ "client_id": client_id
+ }
+
+ # 比较工具列表
+ added_tools = new_tools - {n.split(f"{service_name}_", 1)[-1] if n.startswith(f"{service_name}_") else n for n in old_tools}
+ removed_tools = {n.split(f"{service_name}_", 1)[-1] if n.startswith(f"{service_name}_") else n for n in old_tools} - new_tools
+
+ changes_count = len(added_tools) + len(removed_tools)
+
+ # 无论是否有变化,都用规范化入口回写,确保格式正确(带前缀 + parameters)
+ session = self.registry.get_session(client_id, service_name)
+ if session:
+ locks_owner = getattr(self.orchestrator, 'store', None)
+ agent_locks = getattr(locks_owner, 'agent_locks', None) if locks_owner else None
+ if agent_locks:
+ async with agent_locks.write(client_id):
+ # 使用异步版本避免事件循环冲突
+ await self.registry.replace_service_tools_async(client_id, service_name, session, tools_response)
+ else:
+ # 使用异步版本避免事件循环冲突
+ await self.registry.replace_service_tools_async(client_id, service_name, session, tools_response)
+
+ # 尝试刷新内容(非关键路径,失败忽略)
+ try:
+ await self.orchestrator.content_manager.force_update_service_content(client_id, service_name)
+ except Exception as refresh_err:
+ logger.warning(f"[TOOLS_MONITOR] content_refresh_failed service='{service_name}' error={refresh_err}")
+
+ # 更新时间戳
+ self._update_service_timestamp(service_name, client_id)
+
+ return {
+ "changed": changes_count > 0,
+ "changes_count": changes_count,
+ "added_tools": list(added_tools),
+ "removed_tools": list(removed_tools),
+ "service_name": service_name,
+ "client_id": client_id,
+ "timestamp": datetime.now().isoformat()
+ }
+
+ except Exception as e:
+ logger.error(f"[TOOLS_MONITOR] update_error service='{service_name}' error={e}")
+ return {
+ "changed": False,
+ "error": str(e),
+ "service_name": service_name,
+ "client_id": client_id
+ }
+
+ async def update_service_on_reconnection(self, client_id: str, service_name: str) -> Dict[str, Any]:
+ """
+ 在服务重连后更新工具列表
+
+ Args:
+ client_id: 客户端ID
+ service_name: 服务名称
+
+ Returns:
+ Dict: 更新结果
+ """
+ if not self.update_tools_on_reconnection:
+ logger.debug(f"[TOOLS_MONITOR] reconnection_update_disabled service='{service_name}'")
+ return {"changed": False, "reason": "disabled"}
+
+ logger.info(f"[TOOLS_MONITOR] reconnection_update service='{service_name}'")
+
+ try:
+ result = await self._update_service_tools(client_id, service_name)
+ result["trigger"] = "reconnection"
+
+ if result.get("changed", False):
+ logger.info(f"[TOOLS_MONITOR] reconnection_update changes result={result}")
+ else:
+ logger.debug(f"[TOOLS_MONITOR] reconnection_update no_changes service='{service_name}'")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"[TOOLS_MONITOR] reconnection_update_error service='{service_name}' error={e}")
+ return {
+ "changed": False,
+ "error": str(e),
+ "trigger": "reconnection",
+ "service_name": service_name,
+ "client_id": client_id
+ }
+
+ def get_update_status(self) -> Dict[str, Any]:
+ """
+ 获取更新状态信息
+
+ Returns:
+ Dict: 状态信息
+ """
+ return {
+ "is_running": self.is_running,
+ "enabled": self.enable_tools_update,
+ "update_interval": self.tools_update_interval,
+ "notifications_enabled": self.enable_notifications,
+ "fastmcp_available": FASTMCP_AVAILABLE,
+ "last_update_times": dict(self.last_update_times),
+ "services_count": len(self.last_update_times),
+ "config": {
+ "tools_update_interval": self.tools_update_interval,
+ "enable_tools_update": self.enable_tools_update,
+ "update_tools_on_reconnection": self.update_tools_on_reconnection,
+ "detect_tools_changes": self.detect_tools_changes,
+ "enable_notifications": self.enable_notifications,
+ "notification_debounce_seconds": self.notification_debounce_seconds,
+ "notification_timeout_seconds": self.notification_timeout_seconds,
+ "fallback_to_polling": self.fallback_to_polling
+ }
+ }
+
+ def get_notification_stats(self) -> Dict[str, Any]:
+ """
+ 获取通知统计信息
+
+ Returns:
+ Dict: 通知统计
+ """
+ if self.message_handler:
+ return self.message_handler.get_notification_stats()
+ else:
+ return {"fastmcp_available": False, "message_handler": None}
+
+ def update_config(self, new_config: Dict[str, Any]):
+ """
+ 更新监控配置
+
+ Args:
+ new_config: 新配置
+ """
+ timing_config = new_config.get("timing", {})
+ notification_config = new_config.get("notifications", {})
+
+ # 更新timing配置
+ if "tools_update_interval_seconds" in timing_config:
+ self.tools_update_interval = timing_config["tools_update_interval_seconds"]
+ if "enable_tools_update" in timing_config:
+ self.enable_tools_update = timing_config["enable_tools_update"]
+ if "update_tools_on_reconnection" in timing_config:
+ self.update_tools_on_reconnection = timing_config["update_tools_on_reconnection"]
+ if "detect_tools_changes" in timing_config:
+ self.detect_tools_changes = timing_config["detect_tools_changes"]
+
+ # 更新notification配置
+ if "enable_notifications" in notification_config:
+ self.enable_notifications = notification_config["enable_notifications"] and FASTMCP_AVAILABLE
+ if "debounce_seconds" in notification_config:
+ self.notification_debounce_seconds = notification_config["debounce_seconds"]
+ if "timeout_seconds" in notification_config:
+ self.notification_timeout_seconds = notification_config["timeout_seconds"]
+ if "fallback_to_polling" in notification_config:
+ self.fallback_to_polling = notification_config["fallback_to_polling"]
+
+ logger.info(f"[TOOLS_MONITOR] config_updated")
+
+ def cleanup(self):
+ """清理资源"""
+ logger.debug("[TOOLS_MONITOR] cleanup start")
+
+ # 清理状态数据
+ self.last_update_times.clear()
+ self.last_notification_times.clear()
+
+ # 清理消息处理器
+ if self.message_handler:
+ self.message_handler.clear_notification_history()
+
+ logger.info("[TOOLS_MONITOR] cleanup completed")
diff --git a/src/mcpstore/models/__init__.py b/src/mcpstore/models/__init__.py
new file mode 100644
index 00000000..5404615f
--- /dev/null
+++ b/src/mcpstore/models/__init__.py
@@ -0,0 +1,35 @@
+"""
+Models module - 所有核心模型的统一导出
+
+提供MCPStore的核心数据模型,包括服务、工具、响应等相关模型类。
+"""
+
+# ===== 其他核心模型 =====
+from ..core.models.error_codes import ErrorCode
+# ===== Response相关模型 =====
+from ..core.models.response import APIResponse, ResponseBuilder, ResponseMeta, ErrorDetail
+# ===== Service相关模型 =====
+from ..core.models.service import ServiceInfo, ServiceConnectionState
+# ===== Tool相关模型 =====
+from ..core.models.tool import ToolInfo, ToolExecutionRequest, ToolExecutionResponse
+
+# ===== 公开所有导出 =====
+__all__ = [
+ # Service
+ "ServiceInfo",
+ "ServiceConnectionState",
+
+ # Tool
+ "ToolInfo",
+ "ToolExecutionRequest",
+ "ToolExecutionResponse",
+
+ # Response
+ "APIResponse",
+ "ResponseBuilder",
+ "ResponseMeta",
+ "ErrorDetail",
+
+ # Other
+ "ErrorCode",
+]
\ No newline at end of file
diff --git a/src/mcpstore/plugins/__init__.py b/src/mcpstore/plugins/__init__.py
deleted file mode 100644
index 0519ecba..00000000
--- a/src/mcpstore/plugins/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/mcpstore/plugins/json_mcp.py b/src/mcpstore/plugins/json_mcp.py
deleted file mode 100644
index e602d50b..00000000
--- a/src/mcpstore/plugins/json_mcp.py
+++ /dev/null
@@ -1,237 +0,0 @@
-import os
-import json
-import logging
-from typing import List, Dict, Any, Optional
-from datetime import datetime
-from pydantic import BaseModel, ValidationError, root_validator
-
-logger = logging.getLogger(__name__)
-
-BACKUP_COUNT = 3
-
-class MCPServerModel(BaseModel):
- url: Optional[str] = None
- transport: Optional[str] = None
- command: Optional[str] = None
- args: Optional[List[str]] = None
- env: Optional[Dict[str, str]] = None
- name: Optional[str] = None
-
- @root_validator(pre=True)
- def at_least_one_protocol(cls, values):
- if not (
- values.get("url") or
- values.get("command") or
- values.get("args") or
- values.get("env")
- ):
- raise ValueError("Each MCP server must have at least a url or command/args/env defined")
- return values
-
-class MCPConfigModel(BaseModel):
- mcpServers: Dict[str, MCPServerModel]
-
- @root_validator(pre=True)
- def ensure_mcpServers(cls, values):
- if "mcpServers" not in values:
- values["mcpServers"] = {}
- return values
-
-class ConfigError(Exception):
- """Base class for configuration errors"""
- pass
-
-class ConfigValidationError(ConfigError):
- """Raised when configuration validation fails"""
- pass
-
-class ConfigIOError(ConfigError):
- """Raised when configuration file operations fail"""
- pass
-
-class MCPConfig:
- """Handle loading, parsing and saving of mcp.json file"""
-
- def __init__(self, json_path: str = None, client_id: str = "main"):
- """Initialize configuration manager
-
- Args:
- json_path: Path to the configuration file
- client_id: Client identifier for multi-client support
- """
- self.json_path = json_path or os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "mcp.json")
- self.client_id = client_id
- logger.info(f"MCP configuration initialized for client {client_id}, using file path: {self.json_path}")
-
- def _backup(self) -> None:
- """Create a backup of the current configuration file"""
- if not os.path.exists(self.json_path):
- return
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
- backup_path = f"{self.json_path}.{ts}.bak"
- try:
- with open(self.json_path, 'rb') as src, open(backup_path, 'wb') as dst:
- dst.write(src.read())
- logger.info(f"Backup created: {backup_path}")
-
- # Maintain backup rotation
- backups = sorted([f for f in os.listdir(os.path.dirname(self.json_path))
- if f.startswith(os.path.basename(self.json_path)) and f.endswith('.bak')])
- if len(backups) > BACKUP_COUNT:
- for old in backups[:-BACKUP_COUNT]:
- try:
- os.remove(os.path.join(os.path.dirname(self.json_path), old))
- except Exception as e:
- logger.warning(f"Failed to remove old backup: {old}, {e}")
- except Exception as e:
- logger.error(f"Backup failed: {e}")
- raise ConfigIOError(f"Failed to create backup: {e}")
-
- def load_config(self) -> Dict[str, Any]:
- """Load and validate configuration from file
-
- Returns:
- Dict containing the configuration
-
- Raises:
- ConfigIOError: If file operations fail
- ConfigValidationError: If configuration is invalid
- """
- if not os.path.exists(self.json_path):
- logger.warning(f"Configuration file does not exist: {self.json_path}, creating empty file")
- self.save_config({"mcpServers": {}})
- return {"mcpServers": {}}
-
- try:
- with open(self.json_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- try:
- MCPConfigModel.parse_obj(data)
- except ValidationError as ve:
- raise ConfigValidationError(f"Configuration validation failed: {ve}")
- return data
- except json.JSONDecodeError as e:
- raise ConfigIOError(f"Failed to parse configuration file: {e}")
- except Exception as e:
- raise ConfigIOError(f"Error reading configuration file: {e}")
-
- def save_config(self, config: Dict[str, Any]) -> bool:
- """Save configuration to file with validation
-
- Args:
- config: Configuration dictionary to save
-
- Returns:
- bool: True if save was successful
-
- Raises:
- ConfigValidationError: If configuration is invalid
- ConfigIOError: If file operations fail
- """
- try:
- MCPConfigModel.parse_obj(config)
- except ValidationError as ve:
- raise ConfigValidationError(f"Configuration validation failed: {ve}")
-
- self._backup()
- tmp_path = f"{self.json_path}.tmp"
-
- try:
- with open(tmp_path, 'w', encoding='utf-8') as f:
- json.dump(config, f, ensure_ascii=False, indent=2)
- os.replace(tmp_path, self.json_path)
- logger.info(f"Configuration saved successfully to {self.json_path}")
- return True
- except Exception as e:
- if os.path.exists(tmp_path):
- os.remove(tmp_path)
- raise ConfigIOError(f"Failed to save configuration: {e}")
-
- def get_service_config(self, name: str) -> Optional[Dict[str, Any]]:
- """Get configuration for a specific service
-
- Args:
- name: Service name
-
- Returns:
- Optional[Dict]: Service configuration if found, None otherwise
- """
- config = self.load_config()
- servers = config.get("mcpServers", {})
- if name in servers:
- result = dict(servers[name])
- return result
- return None
-
- def get_all_services(self) -> List[Dict[str, Any]]:
- """Get configuration for all services
-
- Returns:
- List[Dict]: List of service configurations
- """
- config = self.load_config()
- servers = config.get("mcpServers", {})
- return [{"name": name, **server_config} for name, server_config in servers.items()]
-
- def update_service(self, name: str, config: Dict[str, Any]) -> bool:
- """Update or add a service configuration
-
- Args:
- name: Service name
- config: Service configuration
-
- Returns:
- bool: True if update was successful
-
- Raises:
- ConfigValidationError: If service configuration is invalid
- """
- try:
- MCPServerModel.parse_obj(config)
- except ValidationError as ve:
- raise ConfigValidationError(f"Service configuration validation failed: {ve}")
-
- current_config = self.load_config()
- current_config["mcpServers"][name] = config
- return self.save_config(current_config)
-
- def remove_service(self, name: str) -> bool:
- """Remove a service configuration
-
- Args:
- name: Service name
-
- Returns:
- bool: True if removal was successful
- """
- config = self.load_config()
- servers = config.get("mcpServers", {})
- if name in servers:
- del servers[name]
- config["mcpServers"] = servers
- return self.save_config(config)
- return False
-
- def compare_configs(self, new_config: Dict[str, Any]) -> Dict[str, Any]:
- """Compare new configuration with current configuration
-
- Args:
- new_config: New configuration to compare
-
- Returns:
- Dict containing added, removed, and modified services
- """
- current = self.load_config()
- current_servers = current.get("mcpServers", {})
- new_servers = new_config.get("mcpServers", {})
-
- added = set(new_servers.keys()) - set(current_servers.keys())
- removed = set(current_servers.keys()) - set(new_servers.keys())
- modified = {name for name in set(current_servers.keys()) & set(new_servers.keys())
- if current_servers[name] != new_servers[name]}
-
- return {
- "added": list(added),
- "removed": list(removed),
- "modified": list(modified)
- }
diff --git a/src/mcpstore/scripts/api.py b/src/mcpstore/scripts/api.py
index 1aa60170..ca4e2c8b 100644
--- a/src/mcpstore/scripts/api.py
+++ b/src/mcpstore/scripts/api.py
@@ -1,299 +1,72 @@
-"""
-MCPStore API 路由
-提供所有 HTTP API 端点,保持与 MCPStore 核心方法的一致性
-"""
-
-from fastapi import APIRouter, HTTPException, Depends
-from mcpstore import MCPStore
-from mcpstore.core.models.service import (
- RegisterRequestUnion, JsonRegistrationResponse, JsonUpdateRequest,
- JsonConfigResponse, ServiceInfoResponse, ServicesResponse
-)
-from mcpstore.core.models.tool import (
- ToolExecutionRequest, ToolExecutionResponse, ToolsResponse
-)
-from typing import Optional, List, Dict, Any, Union
-from pydantic import BaseModel
-from functools import wraps
-
-# === 统一响应模型 ===
-class APIResponse(BaseModel):
- """统一的API响应格式"""
- success: bool
- data: Optional[Any] = None
- message: Optional[str] = None
-
-# === 工具函数 ===
-def handle_exceptions(func):
- """统一的异常处理装饰器"""
- @wraps(func)
- async def wrapper(*args, **kwargs):
- try:
- result = await func(*args, **kwargs)
- return APIResponse(success=True, data=result)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- return wrapper
-
-def validate_agent_id(agent_id: str):
- """验证 agent_id"""
- if not agent_id:
- raise HTTPException(status_code=400, detail="agent_id is required")
- if not isinstance(agent_id, str):
- raise HTTPException(status_code=400, detail="Invalid agent_id format")
-
-def validate_service_names(service_names: Optional[List[str]]):
- """验证 service_names"""
- if service_names and not isinstance(service_names, list):
- raise HTTPException(status_code=400, detail="Invalid service_names format")
- if service_names and not all(isinstance(name, str) for name in service_names):
- raise HTTPException(status_code=400, detail="All service names must be strings")
-
-router = APIRouter()
-store = MCPStore.setup_store()
-
-# === Store 级别操作 ===
-@router.post("/for_store/add_service", response_model=APIResponse)
-@handle_exceptions
-async def store_add_service(
- payload: Optional[Dict[str, Any]] = None
-):
- """Store 级别注册服务
- 支持三种模式:
- 1. 空参数注册:注册所有 mcp.json 中的服务
- POST /for_store/add_service
-
- 2. URL方式添加服务:
- POST /for_store/add_service
- {
- "name": "weather",
- "url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
- }
-
- 3. 命令方式添加服务:
- POST /for_store/add_service
- {
- "name": "assistant",
- "command": "python",
- "args": ["./assistant_server.py"],
- "env": {"DEBUG": "true"}
- }
-
- Returns:
- APIResponse: {
- "success": true/false,
- "data": true/false, # 是否成功添加服务
- "message": "错误信息(如果有)"
- }
- """
- try:
- context = store.for_store()
-
- # 1. 空参数注册
- if not payload:
- result = await context.add_service()
- return APIResponse(
- success=True,
- data=result,
- message="Successfully registered all services" if result else "Failed to register services"
- )
-
- # 2/3. 配置方式添加服务
- if isinstance(payload, dict):
- if "name" not in payload:
- raise HTTPException(status_code=400, detail="Service name is required")
-
- if "url" in payload and "command" in payload:
- raise HTTPException(status_code=400, detail="Cannot specify both url and command")
-
- if "url" in payload and "transport" not in payload:
- raise HTTPException(status_code=400, detail="Transport type is required for URL-based service")
-
- if "command" in payload and not isinstance(payload.get("args", []), list):
- raise HTTPException(status_code=400, detail="Args must be a list")
-
- result = await context.add_service(payload)
- return APIResponse(
- success=True,
- data=result,
- message="Successfully added service" if result else "Failed to add service"
- )
-
- raise HTTPException(status_code=400, detail="Invalid payload format")
-
- except Exception as e:
- return APIResponse(
- success=False,
- data=False,
- message=str(e)
- )
-
-@router.get("/for_store/list_services", response_model=APIResponse)
-@handle_exceptions
-async def store_list_services():
- """Store 级别获取服务列表"""
- return await store.for_store().list_services()
-
-@router.get("/for_store/list_tools", response_model=APIResponse)
-@handle_exceptions
-async def store_list_tools():
- """Store 级别获取工具列表"""
- return await store.for_store().list_tools()
-
-@router.get("/for_store/check_services", response_model=APIResponse)
-@handle_exceptions
-async def store_check_services():
- """Store 级别健康检查"""
- return await store.for_store().check_services()
-
-@router.post("/for_store/use_tool", response_model=APIResponse)
-@handle_exceptions
-async def store_use_tool(request: ToolExecutionRequest):
- """Store 级别使用工具"""
- if not request.tool_name or not isinstance(request.tool_name, str):
- raise HTTPException(status_code=400, detail="Invalid tool_name")
- if not request.args or not isinstance(request.args, dict):
- raise HTTPException(status_code=400, detail="Invalid args format")
- return await store.for_store().use_tool(request.tool_name, request.args)
-
-# === Agent 级别操作 ===
-@router.post("/for_agent/{agent_id}/add_service", response_model=APIResponse)
-@handle_exceptions
-async def agent_add_service(
- agent_id: str,
- payload: Union[List[str], Dict[str, Any]]
-):
- """Agent 级别注册服务
- 支持两种模式:
- 1. 通过服务名列表注册:
- POST /for_agent/{agent_id}/add_service
- ["服务名1", "服务名2"]
-
- 2. 通过配置添加:
- POST /for_agent/{agent_id}/add_service
- {
- "name": "新服务",
- "command": "python",
- "args": ["service.py"],
- "env": {"DEBUG": "true"}
- }
-
- Args:
- agent_id: Agent ID
- payload: 服务配置或服务名列表
-
- Returns:
- APIResponse: {
- "success": true/false,
- "data": true/false, # 是否成功添加服务
- "message": "错误信息(如果有)"
- }
- """
- try:
- validate_agent_id(agent_id)
- context = store.for_agent(agent_id)
-
- # 1. 服务名列表方式
- if isinstance(payload, list):
- validate_service_names(payload)
- result = await context.add_service(payload)
- return APIResponse(
- success=True,
- data=result,
- message="Successfully registered services" if result else "Failed to register services"
- )
-
- # 2. 配置方式
- if isinstance(payload, dict):
- if "name" not in payload:
- raise HTTPException(status_code=400, detail="Service name is required")
-
- if "url" in payload and "command" in payload:
- raise HTTPException(status_code=400, detail="Cannot specify both url and command")
-
- if "url" in payload and "transport" not in payload:
- raise HTTPException(status_code=400, detail="Transport type is required for URL-based service")
-
- if "command" in payload and not isinstance(payload.get("args", []), list):
- raise HTTPException(status_code=400, detail="Args must be a list")
-
- result = await context.add_service(payload)
- return APIResponse(
- success=True,
- data=result,
- message="Successfully added service" if result else "Failed to add service"
- )
-
- raise HTTPException(status_code=400, detail="Invalid payload format")
-
- except Exception as e:
- return APIResponse(
- success=False,
- data=False,
- message=str(e)
- )
-
-@router.get("/for_agent/{agent_id}/list_services", response_model=APIResponse)
-@handle_exceptions
-async def agent_list_services(agent_id: str):
- """Agent 级别获取服务列表"""
- validate_agent_id(agent_id)
- return await store.for_agent(agent_id).list_services()
-
-@router.get("/for_agent/{agent_id}/list_tools", response_model=APIResponse)
-@handle_exceptions
-async def agent_list_tools(agent_id: str):
- """Agent 级别获取工具列表"""
- validate_agent_id(agent_id)
- return await store.for_agent(agent_id).list_tools()
-
-@router.get("/for_agent/{agent_id}/check_services", response_model=APIResponse)
-@handle_exceptions
-async def agent_check_services(agent_id: str):
- """Agent 级别健康检查"""
- validate_agent_id(agent_id)
- return await store.for_agent(agent_id).check_services()
-
-@router.post("/for_agent/{agent_id}/use_tool", response_model=APIResponse)
-@handle_exceptions
-async def agent_use_tool(agent_id: str, request: ToolExecutionRequest):
- """Agent 级别使用工具"""
- validate_agent_id(agent_id)
- if not request.tool_name or not isinstance(request.tool_name, str):
- raise HTTPException(status_code=400, detail="Invalid tool_name")
- if not request.args or not isinstance(request.args, dict):
- raise HTTPException(status_code=400, detail="Invalid args format")
- return await store.for_agent(agent_id).use_tool(request.tool_name, request.args)
-
-# === 通用服务信息查询 ===
-@router.get("/services/{name}", response_model=APIResponse)
-@handle_exceptions
-async def get_service_info(name: str, agent_id: Optional[str] = None):
- """获取服务信息,支持 Store/Agent 上下文"""
- if agent_id:
- validate_agent_id(agent_id)
- return await store.for_agent(agent_id).get_service_info(name)
- return await store.for_store().get_service_info(name)
-
-# === 配置管理 ===
-@router.get("/config", response_model=APIResponse)
-@handle_exceptions
-async def get_config(agent_id: Optional[str] = None):
- """获取配置,支持 Store/Agent 上下文"""
- if agent_id:
- validate_agent_id(agent_id)
- return store.get_json_config(agent_id)
- return store.get_json_config()
-
-@router.put("/config", response_model=APIResponse)
-@handle_exceptions
-async def update_config(payload: JsonUpdateRequest):
- """更新配置"""
- if not payload.config:
- raise HTTPException(status_code=400, detail="Config is required")
- if payload.client_id:
- validate_agent_id(payload.client_id)
- return await store.update_json_service(payload)
+
+from fastapi import APIRouter
+
+from .api_agent import agent_router
+from .api_cache import router as cache_router
+# Import all sub-route modules
+from .api_store import store_router
+
+# Import dependency injection functions (maintain compatibility)
+
+# Create main router
+router = APIRouter()
+
+# Register all sub-routes
+# Store-level operation routes
+router.include_router(store_router, tags=["Store Operations"])
+
+# Agent-level operation routes
+router.include_router(agent_router, tags=["Agent Operations"])
+
+# Cache read-only routes
+router.include_router(cache_router, tags=["Cache"])
+
+# Maintain backward compatibility - export commonly used functions and classes
+# This way existing import statements can still work normally
+
+# Route statistics information (for debugging)
+def get_route_info():
+ """Get route statistics information"""
+ total_routes = len(router.routes)
+ store_routes = len(store_router.routes)
+ agent_routes = len(agent_router.routes)
+ cache_routes = len(cache_router.routes)
+
+ return {
+ "total_routes": total_routes,
+ "store_routes": store_routes,
+ "agent_routes": agent_routes,
+ "cache_routes": cache_routes,
+ "modules": {
+ "api_store.py": f"{store_routes} routes",
+ "api_agent.py": f"{agent_routes} routes",
+ "api_cache.py": f"{cache_routes} routes",
+ }
+ }
+
+# Health check endpoint (simple root path check)
+@router.get("/", tags=["System"])
+async def api_root():
+ """API root path - system information"""
+ from mcpstore.core.models import ResponseBuilder
+
+ route_info = get_route_info()
+
+ return ResponseBuilder.success(
+ message="MCPStore API is running",
+ data={
+ "service": "MCPStore API",
+ "version": "0.6.0",
+ "status": "operational",
+ "endpoints": {
+ "store": route_info.get("store_routes", 0),
+ "agent": route_info.get("agent_routes", 0),
+ "system": 2
+ },
+ "documentation": {
+ "swagger": "/docs",
+ "redoc": "/redoc",
+ "openapi": "/openapi.json"
+ }
+ }
+ )
diff --git a/src/mcpstore/scripts/api_agent.py b/src/mcpstore/scripts/api_agent.py
new file mode 100644
index 00000000..19963b84
--- /dev/null
+++ b/src/mcpstore/scripts/api_agent.py
@@ -0,0 +1,777 @@
+"""
+MCPStore API - Agent-level routes
+Contains all Agent-level API endpoints
+"""
+
+import logging
+from typing import Dict, Any, Union, List, Optional
+
+from fastapi import APIRouter, Request, Query
+
+from mcpstore.core.models import (
+ APIResponse,
+ ErrorCode,
+ ResponseBuilder,
+ timed_response,
+)
+from .api_decorators import validate_agent_id
+from .api_dependencies import get_store
+from .api_models import (
+ SimpleToolExecutionRequest, create_enhanced_pagination_info
+)
+
+# Create Agent-level router
+agent_router = APIRouter()
+
+logger = logging.getLogger(__name__)
+
+# === Agent-level operations ===
+@agent_router.post("/for_agent/{agent_id}/add_service", response_model=APIResponse)
+@timed_response
+async def agent_add_service(
+ agent_id: str,
+ payload: Union[List[str], Dict[str, Any]]
+):
+ """Add service at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # Manually aggregate details after calling add_service
+ try:
+ await context.bridge_execute(context.add_service_async(payload))
+
+ # Aggregate detailed information(使用 async 版本)
+ services = await context.bridge_execute(context.list_services_async())
+ tools = await context.bridge_execute(context.list_tools_async())
+
+ result = {
+ "success": True,
+ "message": f"Service added successfully for agent '{agent_id}'",
+ "added_services": [s.get("name") if isinstance(s, dict) else getattr(s, "name", "unknown") for s in services],
+ "total_services": len(services),
+ "total_tools": len(tools)
+ }
+
+ return ResponseBuilder.success(
+ message=result["message"],
+ data=result
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_INITIALIZATION_FAILED,
+ message=f"Service operation failed for agent '{agent_id}': {str(e)}",
+ details={"error": str(e)}
+ )
+
+@agent_router.get("/for_agent/{agent_id}/list_services", response_model=APIResponse)
+@timed_response
+async def agent_list_services(
+ agent_id: str,
+ # Pagination parameters (optional)
+ page: Optional[int] = Query(None, ge=1, description="Page number starting from 1. No pagination when omitted."),
+ limit: Optional[int] = Query(None, ge=1, le=1000, description="Items per page. No pagination when omitted."),
+ # Filter parameters (optional)
+ status: Optional[str] = Query(None, description="Filter by status (e.g., healthy, initializing, error)"),
+ search: Optional[str] = Query(None, description="Search by service name (fuzzy match)"),
+ service_type: Optional[str] = Query(None, description="Filter by service type (e.g., sse, stdio)"),
+ # Sort parameters (optional)
+ sort_by: Optional[str] = Query(None, description="Sort field (name, status, type, tools_count)"),
+ sort_order: Optional[str] = Query(None, description="Sort direction (asc, desc)")
+):
+ """
+ Get service list at agent level (supports pagination/filtering/sorting)
+
+ Features:
+ - All parameters are optional, returns all data when no parameters provided
+ - Supports filtering by status, name, type
+ - Supports sorting by multiple fields
+ - Unified response format, always includes pagination field
+
+ Examples:
+ - Get all: GET /for_agent/agent1/list_services
+ - Pagination: GET /for_agent/agent1/list_services?page=1&limit=10
+ - Filter: GET /for_agent/agent1/list_services?status=healthy&service_type=sse
+ - Search: GET /for_agent/agent1/list_services?search=weather
+ - Sort: GET /for_agent/agent1/list_services?sort_by=name&sort_order=asc
+ - Combined: GET /for_agent/agent1/list_services?status=healthy&page=1&limit=10&sort_by=tools_count&sort_order=desc
+ """
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # 1. Get all services
+ all_services = await context.bridge_execute(context.list_services_async())
+
+ # 2. Build complete service data
+ services_data = []
+ for service in all_services:
+ service_data = {
+ "name": service.name,
+ "url": service.url or "",
+ "command": service.command or "",
+ "args": service.args or [],
+ "env": service.env or {},
+ "working_dir": service.working_dir or "",
+ "package_name": service.package_name or "",
+ "keep_alive": service.keep_alive,
+ "type": service.transport_type.value if service.transport_type else 'unknown',
+ "status": service.status.value if hasattr(service.status, 'value') else str(service.status),
+ "tools_count": getattr(service, 'tool_count', 0),
+ "client_id": service.client_id or "",
+ "config": service.config or {}
+ }
+ services_data.append(service_data)
+
+ # 3. Apply filtering
+ filtered = services_data
+ applied_filters = {}
+
+ if status:
+ filtered = [s for s in filtered if s.get("status", "").lower() == status.lower()]
+ applied_filters["status"] = status
+
+ if search:
+ search_lower = search.lower()
+ filtered = [s for s in filtered if search_lower in s.get("name", "").lower()]
+ applied_filters["search"] = search
+
+ if service_type:
+ filtered = [s for s in filtered if s.get("type", "").lower() == service_type.lower()]
+ applied_filters["service_type"] = service_type
+
+ # 4. Apply sorting
+ applied_sort = {}
+ if sort_by:
+ reverse = (sort_order == "desc")
+ if sort_by == "name":
+ filtered.sort(key=lambda s: s.get("name", ""), reverse=reverse)
+ elif sort_by == "status":
+ filtered.sort(key=lambda s: s.get("status", ""), reverse=reverse)
+ elif sort_by == "type":
+ filtered.sort(key=lambda s: s.get("type", ""), reverse=reverse)
+ elif sort_by == "tools_count":
+ filtered.sort(key=lambda s: s.get("tools_count", 0), reverse=reverse)
+
+ applied_sort = {"by": sort_by, "order": sort_order or "asc"}
+
+ filtered_count = len(filtered)
+
+ # 5. Apply pagination
+ if page is not None or limit is not None:
+ # Paginate only when pagination parameters are provided
+ page = page or 1
+ limit = limit or 20
+ start = (page - 1) * limit
+ paginated = filtered[start:start + limit]
+ else:
+ # Return all data when no pagination parameters
+ paginated = filtered
+
+ # 6. Build unified response format (always includes pagination field)
+ pagination = create_enhanced_pagination_info(page, limit, filtered_count)
+
+ response_data = {
+ "services": paginated,
+ "pagination": pagination.dict()
+ }
+
+ # Add filter and sort information (if applied)
+ if applied_filters:
+ response_data["filters"] = applied_filters
+ if applied_sort:
+ response_data["sort"] = applied_sort
+
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(paginated)} of {filtered_count} services for agent '{agent_id}'",
+ data=response_data
+ )
+
+@agent_router.get("/for_agent/{agent_id}/summary", response_model=APIResponse)
+@timed_response
+async def agent_summary(agent_id: str):
+ """Return agent-level statistical summary (object-oriented entry point wrapper)."""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ stats_obj = await context.bridge_execute(
+ context._get_agent_statistics(agent_id)
+ )
+ if hasattr(stats_obj, "__dict__"):
+ stats = dict(stats_obj.__dict__)
+ services = stats.get("services", [])
+ stats["services"] = [s.__dict__ if hasattr(s, "__dict__") else s for s in services]
+ else:
+ stats = stats_obj
+ return ResponseBuilder.success(
+ message=f"Agent '{agent_id}' summary returned",
+ data=stats
+ )
+
+@agent_router.post("/for_agent/{agent_id}/reset_service", response_model=APIResponse)
+@timed_response
+async def agent_reset_service(agent_id: str, request: Request):
+ """Reset service status at agent level (via Application Service)"""
+ validate_agent_id(agent_id)
+ body = await request.json()
+
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # Extract parameters
+ identifier = body.get("identifier")
+ client_id = body.get("client_id")
+ service_name = body.get("service_name")
+
+ used_identifier = service_name or identifier or client_id
+
+ if not used_identifier:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Missing service identifier",
+ field="service_name"
+ )
+
+ # 解析到全局服务名(Agent 视角 → Store 全局命名空间)
+ raw = service_name or identifier or client_id
+ try:
+ resolved_client_id, resolved_service_name = await context.bridge_execute(
+ context._resolve_client_id_async(raw, agent_id)
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message=str(e),
+ field="service_name"
+ )
+
+ global_agent_id = store.client_manager.global_agent_store_id
+ app_service = store.container.service_application_service
+
+ ok = await context.bridge_execute(
+ app_service.reset_service(
+ agent_id=global_agent_id,
+ service_name=resolved_service_name,
+ wait_timeout=0.0,
+ )
+ )
+
+ if not ok:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_OPERATION_FAILED,
+ message=f"Failed to reset service '{used_identifier}' for agent '{agent_id}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{used_identifier}' reset successfully for agent '{agent_id}'",
+ data={"service_name": used_identifier, "agent_id": agent_id, "status": "initializing"}
+ )
+
+@agent_router.get("/for_agent/{agent_id}/list_tools", response_model=APIResponse)
+@timed_response
+async def agent_list_tools(
+ agent_id: str,
+ # Pagination parameters (optional)
+ page: Optional[int] = Query(None, ge=1, description="Page number starting from 1. No pagination when omitted."),
+ limit: Optional[int] = Query(None, ge=1, le=1000, description="Items per page. No pagination when omitted."),
+ # Filter parameters (optional)
+ search: Optional[str] = Query(None, description="Search by tool name or description (fuzzy match)"),
+ service_name: Optional[str] = Query(None, description="Filter by service name (exact match)"),
+ # Sort parameters (optional)
+ sort_by: Optional[str] = Query(None, description="Sort field (name, service)"),
+ sort_order: Optional[str] = Query(None, description="Sort direction (asc, desc)")
+):
+ """
+ Get tool list at agent level (supports pagination/filtering/sorting)
+
+ Features:
+ - All parameters are optional, returns all data when no parameters provided
+ - Supports filtering by tool name, description, service name
+ - Supports sorting by name, service
+ - Unified response format, always includes pagination field
+
+ Examples:
+ - Get all: GET /for_agent/agent1/list_tools
+ - Pagination: GET /for_agent/agent1/list_tools?page=1&limit=20
+ - Search: GET /for_agent/agent1/list_tools?search=read
+ - By service: GET /for_agent/agent1/list_tools?service_name=filesystem
+ - Sort: GET /for_agent/agent1/list_tools?sort_by=name&sort_order=asc
+ - Combined: GET /for_agent/agent1/list_tools?service_name=filesystem&page=1&limit=10&sort_by=name
+ """
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # 1. Get all tools(使用 async 版本)
+ all_tools = await context.bridge_execute(context.list_tools_async())
+
+ # 2. Build tool data
+ tools_data = [
+ {
+ "name": tool.name,
+ "service": getattr(tool, 'service_name', 'unknown'),
+ "description": tool.description or ""
+ }
+ for tool in all_tools
+ ]
+
+ # 3. Apply filtering
+ filtered = tools_data
+ applied_filters = {}
+
+ if search:
+ search_lower = search.lower()
+ filtered = [
+ t for t in filtered
+ if search_lower in t.get("name", "").lower() or search_lower in t.get("description", "").lower()
+ ]
+ applied_filters["search"] = search
+
+ if service_name:
+ filtered = [t for t in filtered if t.get("service", "") == service_name]
+ applied_filters["service_name"] = service_name
+
+ # 4. Apply sorting
+ applied_sort = {}
+ if sort_by:
+ reverse = (sort_order == "desc")
+ if sort_by == "name":
+ filtered.sort(key=lambda t: t.get("name", ""), reverse=reverse)
+ elif sort_by == "service":
+ filtered.sort(key=lambda t: t.get("service", ""), reverse=reverse)
+
+ applied_sort = {"by": sort_by, "order": sort_order or "asc"}
+
+ filtered_count = len(filtered)
+
+ # 5. Apply pagination
+ if page is not None or limit is not None:
+ # Paginate only when pagination parameters are provided
+ page = page or 1
+ limit = limit or 20
+ start = (page - 1) * limit
+ paginated = filtered[start:start + limit]
+ else:
+ # Return all data when no pagination parameters
+ paginated = filtered
+
+ # 6. Build unified response format (always includes pagination field)
+ pagination = create_enhanced_pagination_info(page, limit, filtered_count)
+
+ response_data = {
+ "tools": paginated,
+ "pagination": pagination.dict()
+ }
+
+ # Add filter and sort information (if applied)
+ if applied_filters:
+ response_data["filters"] = applied_filters
+ if applied_sort:
+ response_data["sort"] = applied_sort
+
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(paginated)} of {filtered_count} tools for agent '{agent_id}'",
+ data=response_data
+ )
+
+@agent_router.get("/for_agent/{agent_id}/check_services", response_model=APIResponse)
+@timed_response
+async def agent_check_services(agent_id: str):
+ """Batch health check at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ health_status = await context.bridge_execute(context.check_services_async())
+
+ return ResponseBuilder.success(
+ message=f"Health check completed for agent '{agent_id}'",
+ data=health_status
+ )
+
+@agent_router.post("/for_agent/{agent_id}/call_tool", response_model=APIResponse)
+@timed_response
+async def agent_call_tool(agent_id: str, request: SimpleToolExecutionRequest):
+ """Tool execution at agent level"""
+ validate_agent_id(agent_id)
+
+ store = get_store()
+ context = store.for_agent(agent_id)
+ result = await context.bridge_execute(
+ context.call_tool_async(request.tool_name, request.args)
+ )
+ # 将 FastMCP CallToolResult 标准化为可序列化的视图
+ try:
+ from mcpstore.adapters.common import call_tool_response_helper
+ result_view = call_tool_response_helper(result).model_dump()
+ except Exception as e:
+ # 出现异常时返回原始结果的字符串化内容,避免序列化失败
+ result_view = {
+ "text": str(result),
+ "is_error": True,
+ "error_message": f"call_tool result serialize failed: {e}"
+ }
+
+ return ResponseBuilder.success(
+ message=f"Tool '{request.tool_name}' executed successfully for agent '{agent_id}'",
+ data=result_view
+ )
+
+@agent_router.put("/for_agent/{agent_id}/update_service/{service_name}", response_model=APIResponse)
+@timed_response
+async def agent_update_service(agent_id: str, service_name: str, request: Request):
+ """Update service configuration at agent level"""
+ validate_agent_id(agent_id)
+ body = await request.json()
+
+ store = get_store()
+ context = store.for_agent(agent_id)
+ result = await context.bridge_execute(
+ context.update_service_async(service_name, body)
+ )
+
+ if not result:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Failed to update service '{service_name}' for agent '{agent_id}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' updated for agent '{agent_id}'",
+ data={"service_name": service_name, "agent_id": agent_id}
+ )
+
+@agent_router.delete("/for_agent/{agent_id}/delete_service/{service_name}", response_model=APIResponse)
+@timed_response
+async def agent_delete_service(agent_id: str, service_name: str):
+ """Delete service at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ result = await context.bridge_execute(
+ context.delete_service_async(service_name)
+ )
+
+ if not result:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Failed to delete service '{service_name}' for agent '{agent_id}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' deleted for agent '{agent_id}'",
+ data={"service_name": service_name, "agent_id": agent_id}
+ )
+
+@agent_router.post("/for_agent/{agent_id}/disconnect_service", response_model=APIResponse)
+@timed_response
+async def agent_disconnect_service(agent_id: str, request: Request):
+ """Disconnect service at agent level (lifecycle disconnection without config modification)
+
+ Body example:
+ {
+ "service_name": "localName", # Agent local name
+ "reason": "user_requested"
+ }
+ """
+ validate_agent_id(agent_id)
+ body = await request.json()
+ local_name = body.get("service_name") or body.get("name")
+ reason = body.get("reason", "user_requested")
+
+ if not local_name:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Missing service_name",
+ field="service_name"
+ )
+
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ try:
+ ok = await context.bridge_execute(
+ context.disconnect_service_async(local_name, reason=reason)
+ )
+ if ok:
+ return ResponseBuilder.success(
+ message=f"Service '{local_name}' disconnected for agent '{agent_id}'",
+ data={"agent_id": agent_id, "service_name": local_name, "status": "disconnected"}
+ )
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_OPERATION_FAILED,
+ message=f"Failed to disconnect service '{local_name}' for agent '{agent_id}'",
+ details={"agent_id": agent_id, "service_name": local_name}
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"Failed to disconnect service '{local_name}' for agent '{agent_id}': {e}",
+ details={"agent_id": agent_id, "service_name": local_name}
+ )
+
+@agent_router.get("/for_agent/{agent_id}/show_mcpconfig", response_model=APIResponse)
+@timed_response
+async def agent_show_mcpconfig(agent_id: str):
+ """Get MCP configuration at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ config = await context.bridge_execute(context.show_mcpconfig_async())
+
+ return ResponseBuilder.success(
+ message=f"MCP configuration retrieved for agent '{agent_id}'",
+ data=config
+ )
+
+@agent_router.get("/for_agent/{agent_id}/show_config", response_model=APIResponse)
+@timed_response
+async def agent_show_config(agent_id: str):
+ """Display configuration information at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ config_data = await context.bridge_execute(context.show_config_async())
+
+ # Check for errors
+ if "error" in config_data:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=config_data["error"],
+ details=config_data
+ )
+
+ return ResponseBuilder.success(
+ message=f"Retrieved configuration for agent '{agent_id}'",
+ data=config_data
+ )
+
+@agent_router.delete("/for_agent/{agent_id}/delete_config/{client_id_or_service_name}", response_model=APIResponse)
+@timed_response
+async def agent_delete_config(agent_id: str, client_id_or_service_name: str):
+ """Delete service configuration at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ result = await context.bridge_execute(
+ context.delete_config_async(client_id_or_service_name)
+ )
+
+ if result.get("success"):
+ return ResponseBuilder.success(
+ message=result.get("message", "Configuration deleted successfully"),
+ data=result
+ )
+ else:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=result.get("error", "Failed to delete configuration"),
+ details=result
+ )
+
+@agent_router.put("/for_agent/{agent_id}/update_config/{client_id_or_service_name}", response_model=APIResponse)
+@timed_response
+async def agent_update_config(agent_id: str, client_id_or_service_name: str, new_config: dict):
+ """Update service configuration at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ result = await context.bridge_execute(
+ context.update_config_async(client_id_or_service_name, new_config)
+ )
+
+ if result.get("success"):
+ return ResponseBuilder.success(
+ message=result.get("message", "Configuration updated successfully"),
+ data=result
+ )
+ else:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=result.get("error", "Failed to update configuration"),
+ details=result
+ )
+
+@agent_router.post("/for_agent/{agent_id}/reset_config", response_model=APIResponse)
+@timed_response
+async def agent_reset_config(agent_id: str):
+ """Reset configuration at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ success = await context.bridge_execute(context.reset_config_async())
+
+ if not success:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=f"Failed to reset agent '{agent_id}' configuration",
+ field="agent_id"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Agent '{agent_id}' configuration reset successfully",
+ data={"agent_id": agent_id, "reset": True}
+ )
+
+# === Agent-level Statistics and Monitoring ===
+
+@agent_router.get("/for_agent/{agent_id}/tool_records", response_model=APIResponse)
+@timed_response
+async def get_agent_tool_records(agent_id: str, limit: int = 50):
+ """Get tool execution records at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+ records_data = await context.bridge_execute(
+ context.get_tool_records_async(limit)
+ )
+
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(records_data.get('executions', []))} tool execution records for agent '{agent_id}'",
+ data=records_data
+ )
+
+@agent_router.post("/for_agent/{agent_id}/restart_service", response_model=APIResponse)
+@timed_response
+async def agent_restart_service(agent_id: str, request: Request):
+ """Restart service at agent level"""
+ body = await request.json()
+
+ # Extract parameters
+ service_name = body.get("service_name")
+ if not service_name:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Missing required parameter: service_name",
+ field="service_name"
+ )
+
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # 使用 Agent 解析逻辑将本地服务名解析为全局服务名
+ try:
+ _, global_service_name = await context.bridge_execute(
+ context._resolve_client_id_async(service_name, agent_id)
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=str(e),
+ field="service_name"
+ )
+
+ global_agent_id = store.client_manager.global_agent_store_id
+ app_service = store.container.service_application_service
+
+ result = await context.bridge_execute(
+ app_service.restart_service(
+ service_name=global_service_name,
+ agent_id=global_agent_id,
+ wait_timeout=0.0,
+ )
+ )
+
+ if not result:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_OPERATION_FAILED,
+ message=f"Failed to restart service '{service_name}' for agent '{agent_id}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' restarted for agent '{agent_id}'",
+ data={"agent_id": agent_id, "service_name": service_name, "restarted": True}
+ )
+
+
+# === Agent-level Service Details APIs ===
+
+@agent_router.get("/for_agent/{agent_id}/service_info/{service_name}", response_model=APIResponse)
+@timed_response
+async def agent_get_service_info_detailed(agent_id: str, service_name: str):
+ """Get detailed service information at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # Use SDK to get service information(使用 async 版本)
+ info = await context.bridge_execute(
+ context.get_service_info_async(service_name)
+ )
+ if not info or not getattr(info, 'success', False):
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=getattr(info, 'message', f"Service '{service_name}' not found for agent '{agent_id}'"),
+ field="service_name"
+ )
+
+ # Simplify response structure
+ service = getattr(info, 'service', None)
+ service_info = {
+ "name": service.name,
+ "status": service.status.value if hasattr(service.status, 'value') else str(service.status),
+ "type": service.transport_type.value if service.transport_type else 'unknown',
+ "tools_count": getattr(service, 'tool_count', 0)
+ }
+
+ return ResponseBuilder.success(
+ message=f"Service info retrieved for '{service_name}' in agent '{agent_id}'",
+ data=service_info
+ )
+
+@agent_router.get("/for_agent/{agent_id}/service_status/{service_name}", response_model=APIResponse)
+@timed_response
+async def agent_get_service_status(agent_id: str, service_name: str):
+ """Get service status at agent level"""
+ validate_agent_id(agent_id)
+ store = get_store()
+ context = store.for_agent(agent_id)
+
+ # 使用 Agent 解析逻辑将本地服务名解析为全局服务名
+ try:
+ _, global_service_name = await context.bridge_execute(
+ context._resolve_client_id_async(service_name, agent_id)
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=str(e),
+ field="service_name"
+ )
+
+ global_agent_id = store.client_manager.global_agent_store_id
+ app_service = store.container.service_application_service
+
+ status = await context.bridge_execute(
+ app_service.get_service_status_async(
+ agent_id=global_agent_id,
+ service_name=global_service_name,
+ )
+ )
+
+ # 如果状态为 unknown 且没有 client_id,视为服务不存在或已移除
+ if status.get("status") == "unknown" and not status.get("client_id"):
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Service '{service_name}' not found for agent '{agent_id}'",
+ field="service_name"
+ )
+
+ # 保持原有返回结构:name/status/is_active
+ effective_status = status.get("status", "unknown")
+ is_active = effective_status not in {"disconnected", "unknown", "error"}
+
+ status_info = {
+ "name": service_name,
+ "status": effective_status,
+ "is_active": is_active,
+ }
+
+ return ResponseBuilder.success(
+ message=f"Service status retrieved for '{service_name}' in agent '{agent_id}'",
+ data=status_info
+ )
+
diff --git a/src/mcpstore/scripts/api_app.py b/src/mcpstore/scripts/api_app.py
new file mode 100644
index 00000000..5deaf61b
--- /dev/null
+++ b/src/mcpstore/scripts/api_app.py
@@ -0,0 +1,306 @@
+"""
+MCPStore API Application Factory - 改进版
+支持自定义 URL 前缀和两种启动方式
+"""
+
+import logging
+import time
+from contextlib import asynccontextmanager
+from typing import Optional
+
+from fastapi import Request, FastAPI
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from mcpstore.core.store import MCPStore
+from .api_dependencies import set_request_store, get_store
+from .api_exceptions import (
+ mcpstore_exception_handler,
+ validation_exception_handler,
+ http_exception_handler,
+ general_exception_handler
+)
+
+logger = logging.getLogger(__name__)
+
+
+def create_app(
+ store: Optional[MCPStore] = None,
+ url_prefix: str = ""
+) -> FastAPI:
+ """
+ 创建 FastAPI 应用实例(改进版)
+
+ Args:
+ store: MCPStore 实例。如果为 None,将使用默认配置创建。
+ url_prefix: URL 前缀,如 "/api/v1"。默认为空字符串(无前缀)。
+
+ Returns:
+ FastAPI: 配置好的应用实例
+
+ Example:
+ # 无前缀(默认)
+ app = create_app()
+ # URL: /for_store/list_services
+
+ # 带前缀
+ app = create_app(url_prefix="/api/v1")
+ # URL: /api/v1/for_store/list_services
+
+ # 使用指定的 store
+ my_store = MCPStore.setup_store()
+ app = create_app(store=my_store, url_prefix="/api")
+ """
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ """应用生命周期管理"""
+ # 确定使用的 store 实例
+ if store is None:
+ # CLI 启动:创建默认 store
+ logger.info("No store provided, creating default store")
+ app_store = MCPStore.setup_store()
+ else:
+ # 代码启动:使用传入的 store
+ logger.info("Using provided store instance")
+ app_store = store
+
+ # 保存到应用状态
+ app.state.store = app_store
+ app.state.url_prefix = url_prefix # 保存 URL 前缀配置
+
+ logger.info("Initializing MCPStore API service...")
+
+ if app_store.is_using_data_space():
+ workspace_dir = app_store.get_workspace_dir()
+ logger.info(f"Using data space: {workspace_dir}")
+ else:
+ logger.info("Using default configuration")
+
+ # 初始化编排器
+ try:
+ logger.info("Initializing orchestrator...")
+ await app_store.orchestrator.setup()
+
+ logger.info("MCPStore API service initialized successfully")
+ except Exception as e:
+ logger.error(f"Failed to setup orchestrator: {e}")
+ raise
+
+ yield # 应用运行期间
+
+ # 应用关闭时的清理
+ logger.info("Shutting down MCPStore API service...")
+
+ try:
+ await app_store.orchestrator.cleanup()
+ logger.info("MCPStore API service shutdown completed")
+ except Exception as e:
+ logger.error(f"Error during shutdown: {e}")
+
+ # 创建应用实例
+ logger.info(f"Creating FastAPI app with URL prefix: '{url_prefix or '(none)'}'")
+
+ app = FastAPI(
+ title="MCPStore API",
+ description="MCPStore HTTP API Service",
+ version="1.0.0",
+ lifespan=lifespan
+ )
+
+ # 记录应用启动时间(用于 health check)
+ app._start_time = time.time()
+
+ # 添加中间件:为每个请求设置 store 上下文(线程安全)
+ @app.middleware("http")
+ async def store_context_middleware(request: Request, call_next):
+ """
+ 将 store 注入到请求上下文
+
+ 这个中间件确保每个请求都有独立的 store 上下文,
+ 解决了全局单例的线程安全问题。
+ """
+ store_instance = request.app.state.store
+ set_request_store(store_instance) # 设置到当前请求上下文
+
+ response = await call_next(request)
+ return response
+
+ # 配置 CORS
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # 导入并注册路由(应用 URL 前缀)
+ from .api import router
+
+ if url_prefix:
+ # 如果有前缀,创建一个带前缀的路由器
+ logger.info(f"Applying URL prefix: {url_prefix}")
+ app.include_router(router, prefix=url_prefix)
+ else:
+ # 无前缀,直接注册
+ app.include_router(router)
+
+ # 注册统一的异常处理器
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
+
+ from .api_exceptions import MCPStoreException
+ app.add_exception_handler(MCPStoreException, mcpstore_exception_handler)
+ app.add_exception_handler(Exception, general_exception_handler)
+
+ # 添加请求日志和性能监控中间件
+ @app.middleware("http")
+ async def log_requests_and_monitor(request: Request, call_next):
+ """记录请求日志并监控性能"""
+ start_time = time.time()
+
+ # 增加活跃连接数
+ try:
+ current_store = get_store()
+ current_store.for_store().increment_active_connections()
+ except:
+ pass # 忽略监控错误
+
+ try:
+ response = await call_next(request)
+ process_time = (time.time() - start_time) * 1000
+
+ # 添加响应头
+ response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
+
+ # 记录API调用
+ try:
+ current_store = get_store()
+ current_store.for_store().record_api_call(process_time)
+ except:
+ pass # 忽略监控错误
+
+ # 只记录错误和较慢的请求
+ if response.status_code >= 400 or process_time > 1000:
+ logger.info(
+ f"{request.method} {request.url.path} - "
+ f"Status: {response.status_code}, Duration: {process_time:.2f}ms"
+ )
+ return response
+ except Exception as e:
+ process_time = (time.time() - start_time) * 1000
+ logger.error(
+ f"{request.method} {request.url.path} - "
+ f"Error: {e}, Duration: {process_time:.2f}ms"
+ )
+ raise
+ finally:
+ # 减少活跃连接数
+ try:
+ current_store = get_store()
+ current_store.for_store().decrement_active_connections()
+ except:
+ pass # 忽略监控错误
+
+ # 添加 API 文档入口(根路径或带前缀)
+ @app.get("/doc" if not url_prefix else f"{url_prefix}/doc")
+ async def api_documentation():
+ """
+ API 文档入口
+
+ 返回所有可用的 API 文档链接
+ """
+ from mcpstore.core.models import ResponseBuilder
+
+ doc_prefix = url_prefix or ""
+
+ return ResponseBuilder.success(
+ message="MCPStore API Documentation",
+ data={
+ "documentation": {
+ "swagger_ui": {
+ "url": f"{doc_prefix}/docs",
+ "description": "Swagger UI - 交互式 API 文档,可以直接测试接口"
+ },
+ "redoc": {
+ "url": f"{doc_prefix}/redoc",
+ "description": "ReDoc - 更美观的 API 文档展示"
+ },
+ "openapi_json": {
+ "url": f"{doc_prefix}/openapi.json",
+ "description": "OpenAPI 规范文件(JSON 格式)"
+ }
+ },
+ "quick_links": {
+ "api_root": doc_prefix or "/",
+ "health_check": f"{doc_prefix}/health",
+ "example_service_list": f"{doc_prefix}/for_store/list_services"
+ },
+ "url_prefix": url_prefix if url_prefix else "(none)"
+ }
+ )
+
+ # 添加健康检查端点(根路径或带前缀)
+ @app.get("/health" if not url_prefix else f"{url_prefix}/health")
+ async def health_check():
+ """健康检查端点"""
+ from mcpstore.core.models import ResponseBuilder, ErrorCode
+
+ try:
+ current_store = get_store()
+
+ # 统计服务数量
+ try:
+ context = current_store.for_store()
+ services = context.list_services()
+ services_count = len(services)
+ agents_count = len(current_store.list_all_agents()) if hasattr(current_store, 'list_all_agents') else 0
+ except:
+ services_count = 0
+ agents_count = 0
+
+ # 计算运行时间
+ uptime_seconds = int(time.time() - getattr(app, '_start_time', time.time()))
+
+ return ResponseBuilder.success(
+ message="System is healthy",
+ data={
+ "status": "healthy",
+ "uptime_seconds": uptime_seconds,
+ "services_count": services_count,
+ "agents_count": agents_count,
+ "url_prefix": url_prefix if url_prefix else "(none)"
+ }
+ )
+ except Exception as e:
+ logger.error(f"Health check failed: {e}")
+ response = ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message="Health check failed",
+ details={"error": str(e)}
+ )
+ return JSONResponse(
+ status_code=503,
+ content=response.dict(exclude_none=True)
+ )
+
+ logger.info("FastAPI app created successfully")
+ return app
+
+
+# 为了向后兼容,保留无参数版本
+def create_default_app() -> FastAPI:
+ """
+ 创建默认应用(向后兼容)
+
+ 这个函数保持与旧版本的兼容性。
+ """
+ return create_app()
+
+
+# 为了向后兼容,在模块级别创建默认app实例
+# 注意:这个实例用于 CLI 启动(mcpstore run api)
+app = create_app()
diff --git a/src/mcpstore/scripts/api_cache.py b/src/mcpstore/scripts/api_cache.py
new file mode 100644
index 00000000..ce60243c
--- /dev/null
+++ b/src/mcpstore/scripts/api_cache.py
@@ -0,0 +1,158 @@
+"""
+Cache API routes
+
+只读缓存访问接口,支持 Store/Agent 视角。
+"""
+import logging
+from typing import List, Optional
+
+from fastapi import APIRouter, Query
+
+from mcpstore.core.models import ResponseBuilder, timed_response
+from .api_decorators import validate_agent_id
+from .api_dependencies import get_store
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+def _parse_types(type_param: Optional[str]) -> List[str]:
+ if not type_param:
+ return []
+ if isinstance(type_param, str):
+ return [t.strip() for t in type_param.split(",") if t.strip()]
+ if isinstance(type_param, list):
+ return [t for t in type_param if isinstance(t, str) and t.strip()]
+ return []
+
+def _summary(data: List[dict], cache) -> dict:
+ counts: dict = {}
+ for item in data or []:
+ t = item.get("_type", "unknown")
+ counts[t] = counts.get(t, 0) + 1
+ return {
+ "scope": cache.get_scope() if hasattr(cache, "get_scope") else None,
+ "backend": cache.get_backend_type() if hasattr(cache, "get_backend_type") else None,
+ "counts": counts,
+ }
+
+
+# === Store-level cache ===
+@router.get("/for_store/cache/entities", response_model=None)
+@timed_response
+async def store_cache_entities(type: Optional[str] = Query(None), key: Optional[str] = None):
+ store = get_store()
+ cache = store.find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_entity_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} entities",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_store/cache/relations", response_model=None)
+@timed_response
+async def store_cache_relations(type: Optional[str] = Query(None), key: Optional[str] = None):
+ store = get_store()
+ cache = store.find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_relation_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} relations",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_store/cache/states", response_model=None)
+@timed_response
+async def store_cache_states(type: Optional[str] = Query(None), key: Optional[str] = None):
+ store = get_store()
+ cache = store.find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_state_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} states",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_store/cache/inspect", response_model=None)
+@timed_response
+async def store_cache_inspect():
+ store = get_store()
+ cache = store.find_cache()
+ data = await cache.inspect_async()
+ return ResponseBuilder.success(message="Cache inspect", data=data)
+
+
+@router.get("/for_store/cache/dump", response_model=None)
+@timed_response
+async def store_cache_dump():
+ store = get_store()
+ cache = store.find_cache()
+ data = await cache.dump_all_async()
+ return ResponseBuilder.success(message="Cache dump", data=data)
+
+
+# === Agent-level cache ===
+@router.get("/for_agent/{agent_id}/cache/entities", response_model=None)
+@timed_response
+async def agent_cache_entities(agent_id: str, type: Optional[str] = Query(None), key: Optional[str] = None):
+ validate_agent_id(agent_id)
+ store = get_store()
+ cache = store.for_agent(agent_id).find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_entity_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} entities for agent '{agent_id}'",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_agent/{agent_id}/cache/relations", response_model=None)
+@timed_response
+async def agent_cache_relations(agent_id: str, type: Optional[str] = Query(None), key: Optional[str] = None):
+ validate_agent_id(agent_id)
+ store = get_store()
+ cache = store.for_agent(agent_id).find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_relation_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} relations for agent '{agent_id}'",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_agent/{agent_id}/cache/states", response_model=None)
+@timed_response
+async def agent_cache_states(agent_id: str, type: Optional[str] = Query(None), key: Optional[str] = None):
+ validate_agent_id(agent_id)
+ store = get_store()
+ cache = store.for_agent(agent_id).find_cache()
+ types = _parse_types(type) or None
+ data = await cache.read_state_async(types, key)
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(data)} states for agent '{agent_id}'",
+ data={"items": data, "summary": _summary(data, cache)}
+ )
+
+
+@router.get("/for_agent/{agent_id}/cache/inspect", response_model=None)
+@timed_response
+async def agent_cache_inspect(agent_id: str):
+ validate_agent_id(agent_id)
+ store = get_store()
+ cache = store.for_agent(agent_id).find_cache()
+ data = await cache.inspect_async()
+ return ResponseBuilder.success(message=f"Cache inspect for agent '{agent_id}'", data=data)
+
+
+@router.get("/for_agent/{agent_id}/cache/dump", response_model=None)
+@timed_response
+async def agent_cache_dump(agent_id: str):
+ validate_agent_id(agent_id)
+ store = get_store()
+ cache = store.for_agent(agent_id).find_cache()
+ data = await cache.dump_all_async()
+ return ResponseBuilder.success(message=f"Cache dump for agent '{agent_id}'", data=data)
diff --git a/src/mcpstore/scripts/api_concurrency.py b/src/mcpstore/scripts/api_concurrency.py
new file mode 100644
index 00000000..e9384e34
--- /dev/null
+++ b/src/mcpstore/scripts/api_concurrency.py
@@ -0,0 +1,249 @@
+"""
+MCPStore API Concurrency Control
+并发访问控制模块,用于防止文件操作的竞争条件
+"""
+
+import asyncio
+import logging
+import os
+import sys
+from contextlib import asynccontextmanager
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, AsyncContextManager
+
+# 平台检测
+IS_WINDOWS = sys.platform == "win32"
+
+# Windows 上使用 msvcrt,Unix 上使用 fcntl
+if IS_WINDOWS:
+ import msvcrt
+else:
+ import fcntl
+
+logger = logging.getLogger(__name__)
+
+
+class FileLockManager:
+ """文件锁管理器,用于防止并发文件访问冲突"""
+
+ def __init__(self, lock_dir: str = "/tmp/mcpstore_locks"):
+ self.lock_dir = Path(lock_dir)
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
+ self.active_locks: Dict[str, asyncio.Lock] = {}
+
+ def _get_lock_path(self, file_path: str) -> str:
+ """获取锁文件路径"""
+ # 使用文件路径的哈希作为锁文件名
+ import hashlib
+ file_hash = hashlib.md5(file_path.encode()).hexdigest()
+ return str(self.lock_dir / f"{file_hash}.lock")
+
+ @asynccontextmanager
+ async def acquire_lock(self, file_path: str, timeout: float = 30.0) -> AsyncContextManager[None]:
+ """
+ 获取文件锁
+
+ Args:
+ file_path: 要锁定的文件路径
+ timeout: 获取锁的超时时间
+
+ Yields:
+ None
+
+ Raises:
+ asyncio.TimeoutError: 如果在超时时间内无法获取锁
+ """
+ lock_path = self._get_lock_path(file_path)
+
+ # 为每个文件路径创建一个专用的锁
+ if lock_path not in self.active_locks:
+ self.active_locks[lock_path] = asyncio.Lock()
+
+ file_lock = self.active_locks[lock_path]
+
+ try:
+ # 尝试获取异步锁
+ await asyncio.wait_for(file_lock.acquire(), timeout=timeout)
+
+ # 获取系统级文件锁(防止跨进程冲突)
+ try:
+ with open(lock_path, 'w') as lock_file:
+ if IS_WINDOWS:
+ # Windows 使用锁定文件
+ try:
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
+ except (IOError, OSError):
+ file_lock.release()
+ raise asyncio.TimeoutError(f"Could not acquire system lock for {file_path}")
+ else:
+ # Unix 使用 flock
+ try:
+ fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except (IOError, BlockingIOError):
+ file_lock.release()
+ raise asyncio.TimeoutError(f"Could not acquire system lock for {file_path}")
+
+ lock_file.write(f"{datetime.now().isoformat()}\n{os.getpid()}\n")
+ lock_file.flush()
+ except (IOError, BlockingIOError, OSError):
+ file_lock.release()
+ raise asyncio.TimeoutError(f"Could not acquire system lock for {file_path}")
+
+ logger.debug(f"Acquired lock for {file_path}")
+ yield
+
+ finally:
+ # 释放系统级文件锁
+ try:
+ with open(lock_path, 'r') as lock_file:
+ if IS_WINDOWS:
+ # Windows 上文件会在关闭时自动解锁
+ pass
+ else:
+ # Unix 上需要显式解锁
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
+ except FileNotFoundError:
+ pass
+
+ # 删除锁文件
+ try:
+ os.remove(lock_path)
+ except FileNotFoundError:
+ pass
+
+ # 释放异步锁
+ file_lock.release()
+ logger.debug(f"Released lock for {file_path}")
+
+ def cleanup_stale_locks(self, max_age: timedelta = timedelta(minutes=30)):
+ """清理过期的锁文件"""
+ now = datetime.now()
+ for lock_file in self.lock_dir.glob("*.lock"):
+ try:
+ stat = lock_file.stat()
+ if now - datetime.fromtimestamp(stat.st_mtime) > max_age:
+ lock_file.unlink()
+ logger.debug(f"Cleaned up stale lock: {lock_file}")
+ except Exception as e:
+ logger.warning(f"Failed to clean up lock {lock_file}: {e}")
+
+
+class OperationThrottler:
+ """操作节流器,用于限制高频操作"""
+
+ def __init__(self, max_operations: int = 10, time_window: float = 60.0):
+ self.max_operations = max_operations
+ self.time_window = time_window
+ self.operation_records: Dict[str, list] = {}
+
+ async def check_rate_limit(self, operation_type: str, identifier: str) -> bool:
+ """
+ 检查是否超过速率限制
+
+ Args:
+ operation_type: 操作类型(如 "file_reset", "config_update")
+ identifier: 操作标识符(如文件路径、服务名等)
+
+ Returns:
+ bool: True 表示允许操作,False 表示超过限制
+ """
+ key = f"{operation_type}:{identifier}"
+ now = datetime.now().timestamp()
+
+ if key not in self.operation_records:
+ self.operation_records[key] = []
+
+ # 清理过期的记录
+ window_start = now - self.time_window
+ self.operation_records[key] = [
+ timestamp for timestamp in self.operation_records[key]
+ if timestamp > window_start
+ ]
+
+ # 检查是否超过限制
+ if len(self.operation_records[key]) >= self.max_operations:
+ logger.warning(f"Rate limit exceeded for {key}")
+ return False
+
+ # 记录本次操作
+ self.operation_records[key].append(now)
+ return True
+
+ def get_remaining_operations(self, operation_type: str, identifier: str) -> int:
+ """获取剩余操作次数"""
+ key = f"{operation_type}:{identifier}"
+ if key not in self.operation_records:
+ return self.max_operations
+
+ now = datetime.now().timestamp()
+ window_start = now - self.time_window
+ active_operations = [
+ timestamp for timestamp in self.operation_records[key]
+ if timestamp > window_start
+ ]
+
+ return max(0, self.max_operations - len(active_operations))
+
+
+# 全局实例
+file_lock_manager = FileLockManager()
+operation_throttler = OperationThrottler()
+
+
+@asynccontextmanager
+async def safe_file_operation(
+ file_path: str,
+ operation_type: str = "file_operation",
+ enable_rate_limit: bool = True,
+ rate_limit_max: int = 5,
+ rate_limit_window: float = 60.0
+) -> AsyncContextManager[None]:
+ """
+ 安全的文件操作上下文管理器
+
+ Args:
+ file_path: 要操作的文件路径
+ operation_type: 操作类型,用于速率限制
+ enable_rate_limit: 是否启用速率限制
+ rate_limit_max: 最大操作次数
+ rate_limit_window: 时间窗口(秒)
+
+ Yields:
+ None
+
+ Raises:
+ asyncio.TimeoutError: 如果无法获取锁
+ RuntimeError: 如果超过速率限制
+ """
+ # 检查速率限制
+ if enable_rate_limit:
+ throttler = OperationThrottler(rate_limit_max, rate_limit_window)
+ if not await throttler.check_rate_limit(operation_type, file_path):
+ remaining = throttler.get_remaining_operations(operation_type, file_path)
+ raise RuntimeError(
+ f"Rate limit exceeded for {operation_type} on {file_path}. "
+ f"Remaining operations: {remaining}"
+ )
+
+ # 获取文件锁
+ async with file_lock_manager.acquire_lock(file_path):
+ yield
+
+
+# 定期清理过期锁的任务
+async def cleanup_task():
+ """后台清理任务"""
+ while True:
+ try:
+ file_lock_manager.cleanup_stale_locks()
+ await asyncio.sleep(300) # 每5分钟清理一次
+ except Exception as e:
+ logger.error(f"Lock cleanup task failed: {e}")
+ await asyncio.sleep(60) # 错误时等待1分钟再试
+
+
+def start_cleanup_task():
+ """启动清理任务"""
+ loop = asyncio.get_event_loop()
+ loop.create_task(cleanup_task())
\ No newline at end of file
diff --git a/src/mcpstore/scripts/api_decorators.py b/src/mcpstore/scripts/api_decorators.py
new file mode 100644
index 00000000..bb709d18
--- /dev/null
+++ b/src/mcpstore/scripts/api_decorators.py
@@ -0,0 +1,134 @@
+"""
+MCPStore API Decorators and Utility Functions
+Contains common functionality such as exception handling, performance monitoring, validation, etc.
+"""
+
+import logging
+import time
+from functools import wraps
+from typing import Optional, List
+
+from fastapi import HTTPException
+from pydantic import ValidationError
+
+from mcpstore import MCPStore
+from mcpstore.core.models import APIResponse
+from .api_dependencies import get_store as dependency_get_store
+# 导入统一的异常处理系统
+from .api_exceptions import (
+ MCPStoreException, ValidationException, ErrorCode,
+ error_monitor
+)
+
+logger = logging.getLogger(__name__)
+
+
+# === Decorator functions ===
+
+def handle_exceptions(func):
+ """统一的异常处理装饰器(使用增强版异常处理系统)"""
+ @wraps(func)
+ async def wrapper(*args, **kwargs):
+ try:
+ result = await func(*args, **kwargs)
+ # If result is already APIResponse, return directly
+ if isinstance(result, APIResponse):
+ return result
+ # Otherwise wrap as APIResponse
+ return APIResponse(success=True, data=result)
+ except MCPStoreException:
+ # MCPStore 异常已经包含足够信息,直接抛出
+ raise
+ except HTTPException:
+ # HTTPException 应该直接传递,不要包装
+ raise
+ except ValidationError as e:
+ # Pydantic 验证错误
+ raise ValidationException(
+ message=f"Data validation error: {str(e)}",
+ details={"validation_errors": e.errors()}
+ )
+ except ValueError as e:
+ # 值错误
+ raise ValidationException(message=str(e))
+ except KeyError as e:
+ # 键错误
+ raise ValidationException(
+ message=f"Missing required field: {str(e)}",
+ field=str(e)
+ )
+ except Exception as e:
+ # 记录未处理的异常
+ error_monitor.record_error(e, {"function": func.__name__})
+ logger.error(f"Unhandled exception in {func.__name__}: {str(e)}", exc_info=True)
+ raise MCPStoreException(
+ message=f"Internal server error in {func.__name__}",
+ error_code=ErrorCode.INTERNAL_ERROR,
+ details={
+ "function": func.__name__,
+ "type": type(e).__name__
+ }
+ )
+ return wrapper
+
+def monitor_api_performance(func):
+ """API performance monitoring decorator"""
+ @wraps(func)
+ async def wrapper(*args, **kwargs):
+ start_time = time.time()
+
+ # Get store instance (from dependency injection)
+ store = None
+ for arg in args:
+ if isinstance(arg, MCPStore):
+ store = arg
+ break
+
+ # 如果没有在args中找到,检查kwargs
+ if store is None:
+ store = kwargs.get('store')
+
+ try:
+ # 增加活跃连接数
+ store = dependency_get_store()
+ store.for_store().increment_active_connections()
+
+ result = await func(*args, **kwargs)
+
+ # 记录API调用
+ if store:
+ response_time = (time.time() - start_time) * 1000 # 转换为毫秒
+ store.for_store().record_api_call(response_time)
+
+ return result
+ finally:
+ # 减少活跃连接数
+ if store:
+ store.for_store().decrement_active_connections()
+
+ return wrapper
+
+# === 验证函数 ===
+
+def validate_agent_id(agent_id: str):
+ """验证 agent_id"""
+ if not agent_id:
+ raise HTTPException(status_code=400, detail="agent_id is required")
+ if not isinstance(agent_id, str):
+ raise HTTPException(status_code=400, detail="Invalid agent_id format")
+
+ # 检查agent_id格式:只允许字母、数字、下划线、连字符
+ import re
+ if not re.match(r'^[a-zA-Z0-9_-]+$', agent_id):
+ raise HTTPException(status_code=400, detail="Invalid agent_id format: only letters, numbers, underscore and hyphen allowed")
+
+ # 检查长度
+ if len(agent_id) > 100:
+ raise HTTPException(status_code=400, detail="agent_id too long (max 100 characters)")
+
+def validate_service_names(service_names: Optional[List[str]]):
+ """验证 service_names"""
+ if service_names and not isinstance(service_names, list):
+ raise HTTPException(status_code=400, detail="Invalid service_names format")
+ if service_names and not all(isinstance(name, str) for name in service_names):
+ raise HTTPException(status_code=400, detail="All service names must be strings")
diff --git a/src/mcpstore/scripts/api_dependencies.py b/src/mcpstore/scripts/api_dependencies.py
new file mode 100644
index 00000000..5dab2ba2
--- /dev/null
+++ b/src/mcpstore/scripts/api_dependencies.py
@@ -0,0 +1,61 @@
+"""
+MCPStore API Dependencies - 改进版
+使用 contextvars 实现线程安全的 Store 管理
+"""
+
+from contextvars import ContextVar
+from typing import Optional
+
+from mcpstore import MCPStore
+
+# 使用 contextvars(Python 3.7+ 标准库,线程安全)
+_store_context: ContextVar[Optional[MCPStore]] = ContextVar('store', default=None)
+
+
+def get_store() -> MCPStore:
+ """
+ 获取当前请求的 Store 实例(线程安全)
+
+ 这个函数会从请求上下文中获取 store 实例。
+ 上下文由中间件自动设置,确保每个请求都有独立的上下文。
+
+ Returns:
+ MCPStore: 当前请求的 store 实例
+
+ Raises:
+ RuntimeError: 如果 store 未初始化
+
+ Note:
+ - 用户代码无需修改,函数签名保持不变
+ - 支持多 worker 部署(线程安全)
+ - 每个请求有独立的上下文(互不干扰)
+ """
+ store = _store_context.get()
+ if store is None:
+ raise RuntimeError(
+ "Store not initialized in request context. "
+ "This should not happen if the middleware is properly configured."
+ )
+ return store
+
+
+def set_request_store(store: MCPStore) -> None:
+ """
+ 为当前请求设置 Store 实例
+
+ 此函数由中间件调用,用户代码不需要直接调用。
+
+ Args:
+ store: MCPStore 实例
+ """
+ _store_context.set(store)
+
+
+def has_store_context() -> bool:
+ """
+ 检查当前上下文是否已设置 store 实例
+
+ Returns:
+ bool: 如果已设置返回 True,否则返回 False
+ """
+ return _store_context.get() is not None
diff --git a/src/mcpstore/scripts/api_exceptions.py b/src/mcpstore/scripts/api_exceptions.py
new file mode 100644
index 00000000..bdeda0e3
--- /dev/null
+++ b/src/mcpstore/scripts/api_exceptions.py
@@ -0,0 +1,319 @@
+"""
+MCPStore API Unified Exception Handling
+Provides comprehensive exception handling and error response formatting
+"""
+
+import logging
+import traceback
+import uuid
+from datetime import datetime
+from typing import Optional, Dict, Any, Union, List
+
+from fastapi import Request, HTTPException
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from pydantic import ValidationError
+
+# Import unified exception system
+from mcpstore.core.exceptions import (
+ MCPStoreException,
+ ErrorCode,
+ ValidationException,
+)
+# Import new response models
+from mcpstore.core.models import (
+ APIResponse,
+ ResponseBuilder
+)
+
+# Setup logger
+logger = logging.getLogger(__name__)
+
+# === Exception classes are now imported from mcpstore.core.exceptions ===
+# No need to redefine them here
+
+# === Error response formatting (using new architecture) ===
+
+def format_error_response(
+ error: Union[MCPStoreException, Exception],
+ include_stack_trace: bool = False
+) -> APIResponse:
+ """Format error response (using new APIResponse model)"""
+
+ if isinstance(error, MCPStoreException):
+ # Build details, may include stack trace
+ details = {**error.details, "error_id": error.error_id}
+ if include_stack_trace and error.stack_trace:
+ details["stack_trace"] = error.stack_trace
+
+ return ResponseBuilder.error(
+ code=error.error_code,
+ message=error.message,
+ field=error.field,
+ details=details
+ )
+ else:
+ # Standard exception handling
+ details = {
+ "error_id": str(uuid.uuid4())[:8],
+ "error_type": type(error).__name__
+ }
+ if include_stack_trace:
+ details["stack_trace"] = traceback.format_exc()
+
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=str(error) or "Internal server error",
+ details=details
+ )
+
+# === Exception Handlers ===
+
+async def mcpstore_exception_handler(request: Request, exc: MCPStoreException):
+ """MCPStore exception handler (using new response format)"""
+ logger.error(
+ f"MCPStore error [{exc.error_id}]: {exc.message}",
+ extra={
+ "error_code": exc.error_code,
+ "status_code": exc.status_code,
+ "details": exc.details,
+ "error_id": exc.error_id,
+ "path": request.url.path,
+ "method": request.method
+ }
+ )
+
+ response = format_error_response(exc, include_stack_trace=False)
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=response.dict(exclude_none=True)
+ )
+
+async def validation_exception_handler(request: Request, exc: RequestValidationError):
+ """Request validation exception handler (using new response format)"""
+ # Convert to ErrorDetail list
+ error_details = []
+ for error in exc.errors():
+ field = " -> ".join([str(loc) for loc in error["loc"] if loc != "body"])
+ error_details.append({
+ "code": ErrorCode.INVALID_PARAMETER.value,
+ "message": error["msg"],
+ "field": field,
+ "details": {"type": error["type"]}
+ })
+
+ logger.warning(
+ f"Validation error: {len(error_details)} errors",
+ extra={
+ "errors": error_details,
+ "path": request.url.path,
+ "method": request.method
+ }
+ )
+
+ response = ResponseBuilder.errors(
+ message=f"Request validation failed ({len(error_details)} errors)",
+ errors=error_details
+ )
+
+ return JSONResponse(
+ status_code=422,
+ content=response.dict(exclude_none=True)
+ )
+
+async def http_exception_handler(request: Request, exc: HTTPException):
+ """HTTP exception handler (using new response format)"""
+ logger.warning(
+ f"HTTP error: {exc.status_code} - {exc.detail}",
+ extra={
+ "status_code": exc.status_code,
+ "path": request.url.path,
+ "method": request.method
+ }
+ )
+
+ # Map HTTP status codes to error codes
+ error_code_map = {
+ 404: ErrorCode.SERVICE_NOT_FOUND,
+ 401: ErrorCode.AUTHENTICATION_REQUIRED,
+ 403: ErrorCode.AUTHORIZATION_FAILED,
+ 400: ErrorCode.INVALID_REQUEST,
+ 429: ErrorCode.RATE_LIMIT_EXCEEDED,
+ }
+ error_code = error_code_map.get(exc.status_code, ErrorCode.INTERNAL_ERROR)
+
+ response = ResponseBuilder.error(
+ code=error_code,
+ message=exc.detail or "HTTP error",
+ details={"http_status": exc.status_code}
+ )
+
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=response.dict(exclude_none=True)
+ )
+
+async def general_exception_handler(request: Request, exc: Exception):
+ """General exception handler (using new response format)"""
+ error_id = str(uuid.uuid4())[:8]
+ logger.error(
+ f"Unhandled exception [{error_id}]: {str(exc)}",
+ extra={
+ "error_id": error_id,
+ "path": request.url.path,
+ "method": request.method,
+ "stack_trace": traceback.format_exc()
+ },
+ exc_info=True
+ )
+
+ response = ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message="Internal server error",
+ details={
+ "error_id": error_id,
+ "error_type": type(exc).__name__
+ }
+ )
+
+ return JSONResponse(
+ status_code=500,
+ content=response.dict(exclude_none=True)
+ )
+
+# === Exception Handling Decorators ===
+
+def handle_api_exceptions(func):
+ """API exception handling decorator (enhanced version)"""
+ import functools
+
+ @functools.wraps(func)
+ async def wrapper(*args, **kwargs):
+ try:
+ result = await func(*args, **kwargs)
+
+ # If result is already APIResponse, return directly
+ if isinstance(result, APIResponse):
+ return result
+
+ # Otherwise wrap as success response
+ return ResponseBuilder.success(
+ message="Operation completed successfully",
+ data=result if isinstance(result, (dict, list)) else {"result": result}
+ )
+
+ except MCPStoreException:
+ # MCPStore exceptions already contain sufficient information, raise directly
+ raise
+
+ except HTTPException:
+ # HTTPException should be passed through directly, not wrapped
+ raise
+
+ except RequestValidationError:
+ # FastAPI validation errors, let global handler process
+ raise
+
+ except ValidationError as e:
+ # Pydantic validation error
+ raise ValidationException(
+ message=f"Data validation error: {str(e)}",
+ details={"validation_errors": e.errors()}
+ )
+
+ except ValueError as e:
+ # Value error
+ raise ValidationException(message=str(e))
+
+ except KeyError as e:
+ # Key error
+ raise ValidationException(
+ message=f"Missing required field: {str(e)}",
+ field=str(e)
+ )
+
+ except AttributeError as e:
+ # Attribute error
+ raise MCPStoreException(
+ message=f"Attribute error: {str(e)}",
+ error_code=ErrorCode.INTERNAL_ERROR,
+ details={"attribute": str(e)}
+ )
+
+ except Exception as e:
+ # All other exceptions
+ error_id = str(uuid.uuid4())[:8]
+ logger.error(
+ f"Unhandled API exception [{error_id}]: {str(e)}",
+ extra={
+ "error_id": error_id,
+ "function": func.__name__,
+ "stack_trace": traceback.format_exc()
+ },
+ exc_info=True
+ )
+
+ raise MCPStoreException(
+ message=f"Internal server error [{error_id}]",
+ error_code=ErrorCode.INTERNAL_ERROR,
+ details={
+ "function": func.__name__,
+ "type": type(e).__name__
+ },
+ stack_trace=traceback.format_exc()
+ )
+
+ return wrapper
+
+# === Error Monitoring and Reporting ===
+
+class ErrorMonitor:
+ """Error monitor"""
+
+ def __init__(self):
+ self.error_counts: Dict[str, int] = {}
+ self.recent_errors: List[Dict[str, Any]] = []
+ self.max_recent_errors = 100
+
+ def record_error(self, error: Union[MCPStoreException, Exception], context: Optional[Dict[str, Any]] = None):
+ """Record error"""
+ # Handle ErrorCode enum
+ if isinstance(error, MCPStoreException):
+ error_code = error.error_code
+ else:
+ error_code = ErrorCode.INTERNAL_ERROR.value
+
+ # Update error count
+ self.error_counts[error_code] = self.error_counts.get(error_code, 0) + 1
+
+ # Record recent error
+ error_info = {
+ "error_id": getattr(error, 'error_id', str(uuid.uuid4())[:8]),
+ "error_code": error_code,
+ "message": str(error),
+ "timestamp": datetime.utcnow().isoformat(),
+ "context": context or {}
+ }
+
+ self.recent_errors.append(error_info)
+
+ # Keep recent errors list within limits
+ if len(self.recent_errors) > self.max_recent_errors:
+ self.recent_errors = self.recent_errors[-self.max_recent_errors:]
+
+ def get_error_stats(self) -> Dict[str, Any]:
+ """Get error statistics"""
+ return {
+ "total_errors": sum(self.error_counts.values()),
+ "error_counts": self.error_counts,
+ "recent_errors": self.recent_errors[-10:], # Last 10 errors
+ "unique_error_codes": len(self.error_counts)
+ }
+
+ def clear_stats(self):
+ """Clear statistics"""
+ self.error_counts.clear()
+ self.recent_errors.clear()
+
+# Global error monitor instance
+error_monitor = ErrorMonitor()
diff --git a/src/mcpstore/scripts/api_models.py b/src/mcpstore/scripts/api_models.py
new file mode 100644
index 00000000..3a28dc87
--- /dev/null
+++ b/src/mcpstore/scripts/api_models.py
@@ -0,0 +1,396 @@
+"""
+MCPStore API Response Models
+Contains request and response models used by all API endpoints
+"""
+
+from typing import Optional, List, Dict, Any
+
+from pydantic import BaseModel, Field
+
+
+# === Monitoring-related response models ===
+
+class ToolUsageStatsResponse(BaseModel):
+ """Tool usage statistics response"""
+ tool_name: str = Field(description="Tool name")
+ service_name: str = Field(description="Service name")
+ execution_count: int = Field(description="Execution count")
+ last_executed: Optional[str] = Field(description="Last execution time")
+ average_response_time: float = Field(description="Average response time")
+ success_rate: float = Field(description="Success rate")
+
+class ToolExecutionRecordResponse(BaseModel):
+ """Tool execution record response"""
+ id: str = Field(description="Record ID")
+ tool_name: str = Field(description="Tool name")
+ service_name: str = Field(description="Service name")
+ params: Dict[str, Any] = Field(description="Execution parameters")
+ result: Optional[Any] = Field(description="Execution result")
+ error: Optional[str] = Field(description="Error message")
+ response_time: float = Field(description="Response time (milliseconds)")
+ execution_time: str = Field(description="Execution time")
+ timestamp: int = Field(description="Timestamp")
+
+class ToolRecordsSummaryResponse(BaseModel):
+ """工具记录汇总响应"""
+ total_executions: int = Field(description="总执行次数")
+ by_tool: Dict[str, Dict[str, Any]] = Field(description="按工具统计")
+ by_service: Dict[str, Dict[str, Any]] = Field(description="按服务统计")
+
+class ToolRecordsResponse(BaseModel):
+ """工具记录完整响应"""
+ executions: List[ToolExecutionRecordResponse] = Field(description="执行记录列表")
+ summary: ToolRecordsSummaryResponse = Field(description="汇总统计")
+
+class AddAlertRequest(BaseModel):
+ """添加告警请求"""
+ type: str = Field(description="告警类型: warning, error, info")
+ title: str = Field(description="告警标题")
+ message: str = Field(description="告警消息")
+ service_name: Optional[str] = Field(None, description="相关服务名称")
+
+# === 健康状态相关响应模型 ===
+class ServiceHealthResponse(BaseModel):
+ """服务健康状态响应"""
+ service_name: str = Field(description="服务名称")
+ status: str = Field(description="服务状态: initializing, healthy, warning, reconnecting, unreachable, disconnecting, disconnected")
+ response_time: float = Field(description="最近响应时间(秒)")
+ last_check_time: float = Field(description="最后检查时间戳")
+ consecutive_failures: int = Field(description="连续失败次数")
+ consecutive_successes: int = Field(description="连续成功次数")
+ reconnect_attempts: int = Field(description="重连尝试次数")
+ state_entered_time: Optional[str] = Field(None, description="状态进入时间")
+ next_retry_time: Optional[str] = Field(None, description="下次重试时间")
+ error_message: Optional[str] = Field(None, description="错误信息")
+ details: Dict[str, Any] = Field(default_factory=dict, description="详细信息")
+
+class HealthSummaryResponse(BaseModel):
+ """健康状态汇总响应"""
+ total_services: int = Field(description="总服务数量")
+ initializing_count: int = Field(description="初始化中服务数量")
+ healthy_count: int = Field(description="健康服务数量")
+ warning_count: int = Field(description="警告状态服务数量")
+ reconnecting_count: int = Field(description="重连中服务数量")
+ unreachable_count: int = Field(description="无法访问服务数量")
+ disconnecting_count: int = Field(description="断连中服务数量")
+ disconnected_count: int = Field(description="已断连服务数量")
+ services: Dict[str, ServiceHealthResponse] = Field(description="各服务健康状态详情")
+
+# === Agent统计相关响应模型 ===
+class AgentServiceSummaryResponse(BaseModel):
+ """Agent服务摘要响应"""
+ service_name: str = Field(description="服务名称")
+ service_type: str = Field(description="服务类型")
+ status: str = Field(description="服务状态: initializing, healthy, warning, reconnecting, unreachable, disconnecting, disconnected")
+ tool_count: int = Field(description="工具数量")
+ last_used: Optional[str] = Field(None, description="最后使用时间")
+ client_id: Optional[str] = Field(None, description="客户端ID")
+ response_time: Optional[float] = Field(None, description="最近响应时间(秒)")
+ health_details: Optional[Dict[str, Any]] = Field(None, description="健康状态详情")
+
+class AgentStatisticsResponse(BaseModel):
+ """Agent统计信息响应"""
+ agent_id: str = Field(description="Agent ID")
+ service_count: int = Field(description="服务数量")
+ tool_count: int = Field(description="工具数量")
+ healthy_services: int = Field(description="健康服务数量")
+ unhealthy_services: int = Field(description="不健康服务数量")
+ total_tool_executions: int = Field(description="总工具执行次数")
+ last_activity: Optional[str] = Field(None, description="最后活动时间")
+ services: List[AgentServiceSummaryResponse] = Field(description="服务列表")
+
+class AgentsSummaryResponse(BaseModel):
+ """所有Agent汇总信息响应"""
+ total_agents: int = Field(description="总Agent数量")
+ active_agents: int = Field(description="活跃Agent数量")
+ total_services: int = Field(description="总服务数量")
+ total_tools: int = Field(description="总工具数量")
+ store_services: int = Field(description="Store级别服务数量")
+ store_tools: int = Field(description="Store级别工具数量")
+ agents: List[AgentStatisticsResponse] = Field(description="Agent列表")
+
+# === 工具执行请求模型 ===
+class SimpleToolExecutionRequest(BaseModel):
+ """简化的工具执行请求模型(用于API)"""
+ tool_name: str = Field(..., description="工具名称")
+ args: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
+ service_name: Optional[str] = Field(None, description="服务名称(可选,会自动推断)")
+
+# === 生命周期配置模型 ===
+class ServiceLifecycleConfig(BaseModel):
+ """服务生命周期配置模型"""
+ # 状态转换阈值
+ warning_failure_threshold: Optional[int] = Field(default=None, ge=1, le=10, description="进入WARNING状态的失败阈值,范围1-10")
+ reconnecting_failure_threshold: Optional[int] = Field(default=None, ge=2, le=10, description="进入RECONNECTING状态的失败阈值,范围2-10")
+ max_reconnect_attempts: Optional[int] = Field(default=None, ge=3, le=20, description="最大重连尝试次数,范围3-20")
+ warning_ping_timeout: Optional[float] = Field(default=None, ge=1, description="WARNING/RECONNECTING状态下的健康检查超时(秒),缺省使用默认值")
+ ping_timeout_http: Optional[float] = Field(default=None, ge=1, description="HTTP 传输健康检查超时(秒)")
+ ping_timeout_sse: Optional[float] = Field(default=None, ge=1, description="SSE 传输健康检查超时(秒)")
+ ping_timeout_stdio: Optional[float] = Field(default=None, ge=1, description="STDIO/Studio 传输健康检查超时(秒)")
+
+# === 服务详情相关响应模型 ===
+
+class ServiceLifecycleInfo(BaseModel):
+ """服务生命周期信息"""
+ consecutive_successes: int = Field(description="连续成功次数")
+ consecutive_failures: int = Field(description="连续失败次数")
+ last_ping_time: Optional[str] = Field(None, description="最后ping时间")
+ error_message: Optional[str] = Field(None, description="错误信息")
+ reconnect_attempts: int = Field(description="重连尝试次数")
+ state_entered_time: Optional[str] = Field(None, description="状态进入时间")
+
+class ServiceToolInfo(BaseModel):
+ """服务工具信息"""
+ name: str = Field(description="工具名称")
+ description: Optional[str] = Field(None, description="工具描述")
+ input_schema: Optional[Dict[str, Any]] = Field(None, description="输入模式")
+ service_name: str = Field(description="所属服务名称")
+
+class ServiceHealthDetail(BaseModel):
+ """服务健康详情"""
+ status: str = Field(description="健康状态")
+ message: Optional[str] = Field(None, description="健康消息")
+ timestamp: Optional[str] = Field(None, description="检查时间戳")
+ uptime: Optional[str] = Field(None, description="运行时间")
+ error_count: int = Field(default=0, description="错误计数")
+ last_error: Optional[str] = Field(None, description="最后错误")
+ response_time: Optional[float] = Field(None, description="响应时间(毫秒)")
+ is_healthy: bool = Field(description="是否健康")
+
+class ServiceDetailResponse(BaseModel):
+ """服务详细信息响应"""
+ name: str = Field(description="服务名称")
+ status: str = Field(description="服务状态")
+ transport: str = Field(description="传输类型")
+ client_id: Optional[str] = Field(None, description="客户端ID")
+ url: Optional[str] = Field(None, description="服务URL")
+ command: Optional[str] = Field(None, description="启动命令")
+ args: Optional[List[str]] = Field(None, description="命令参数")
+ env: Optional[Dict[str, str]] = Field(None, description="环境变量")
+ tool_count: int = Field(description="工具数量")
+ is_active: bool = Field(description="是否已激活")
+ config: Dict[str, Any] = Field(default_factory=dict, description="配置信息")
+ lifecycle: Optional[ServiceLifecycleInfo] = Field(None, description="生命周期信息")
+ tools: List[ServiceToolInfo] = Field(default_factory=list, description="工具列表")
+ health: Optional[ServiceHealthDetail] = Field(None, description="健康信息")
+
+class ServiceStatusResponse(BaseModel):
+ """服务状态响应"""
+ name: str = Field(description="服务名称")
+ status: str = Field(description="服务状态")
+ is_active: bool = Field(description="是否已激活")
+ client_id: Optional[str] = Field(None, description="客户端ID")
+ last_updated: Optional[str] = Field(None, description="最后更新时间")
+ consecutive_successes: int = Field(default=0, description="连续成功次数")
+ consecutive_failures: int = Field(default=0, description="连续失败次数")
+ error_message: Optional[str] = Field(None, description="错误信息")
+ reconnect_attempts: int = Field(default=0, description="重连尝试次数")
+
+# === 数据空间相关响应模型 ===
+
+class WorkspaceInfo(BaseModel):
+ """工作空间信息"""
+ name: str = Field(description="工作空间名称")
+ path: str = Field(description="工作空间路径")
+ mcp_config_path: str = Field(description="MCP配置文件路径")
+ is_current: bool = Field(description="是否为当前工作空间")
+
+class DataSpaceInfo(BaseModel):
+ """数据空间信息"""
+ is_using_data_space: bool = Field(description="是否使用数据空间")
+ workspace_dir: Optional[str] = Field(None, description="工作空间目录")
+ mcp_config_path: Optional[str] = Field(None, description="MCP配置文件路径")
+ data_space_path: Optional[str] = Field(None, description="数据空间路径")
+ workspace_config: Dict[str, Any] = Field(default_factory=dict, description="工作空间配置")
+
+class WorkspacesListResponse(BaseModel):
+ """工作空间列表响应"""
+ workspaces: List[WorkspaceInfo] = Field(description="工作空间列表")
+ current_workspace: Optional[str] = Field(None, description="当前工作空间路径")
+ using_default: bool = Field(default=False, description="是否使用默认配置")
+
+# === LangChain 相关响应模型 ===
+
+class LangChainToolParameter(BaseModel):
+ """LangChain工具参数信息"""
+ required: List[str] = Field(default_factory=list, description="必需参数")
+ optional: List[str] = Field(default_factory=list, description="可选参数")
+ total_count: int = Field(default=0, description="参数总数")
+
+class LangChainToolResponse(BaseModel):
+ """LangChain工具响应"""
+ name: str = Field(description="工具名称")
+ description: str = Field(description="工具描述")
+ args_schema: Optional[Dict[str, Any]] = Field(None, description="参数模式")
+ is_structured: bool = Field(description="是否为结构化工具")
+ tool_type: str = Field(description="工具类型")
+ parameters: Optional[LangChainToolParameter] = Field(None, description="参数信息")
+ original_info: Optional[Dict[str, Any]] = Field(None, description="原始工具信息")
+
+class LangChainToolsListResponse(BaseModel):
+ """LangChain工具列表响应"""
+ tools: List[LangChainToolResponse] = Field(description="工具列表")
+ total_tools: int = Field(description="工具总数")
+ structured_tools: int = Field(description="结构化工具数量")
+
+# === 批量操作请求模型 ===
+
+class BatchServiceOperationRequest(BaseModel):
+ """批量服务操作请求"""
+ service_names: List[str] = Field(..., description="服务名称列表")
+ operation: str = Field(..., description="操作类型: init, start, stop, restart, delete")
+
+class BatchServiceOperationResponse(BaseModel):
+ """批量服务操作响应"""
+ total_count: int = Field(description="总数")
+ success_count: int = Field(description="成功数量")
+ failure_count: int = Field(description="失败数量")
+ results: List[Dict[str, Any]] = Field(description="各服务操作结果")
+
+# === API分页模型 ===
+
+class PaginationParams(BaseModel):
+ """分页参数"""
+ page: int = Field(default=1, ge=1, description="页码")
+ page_size: int = Field(default=20, ge=1, le=100, description="每页大小")
+
+class PaginatedResponse(BaseModel):
+ """分页响应基类"""
+ items: List[Any] = Field(description="数据项")
+ total: int = Field(description="总数")
+ page: int = Field(description="当前页码")
+ page_size: int = Field(description="每页大小")
+ total_pages: int = Field(description="总页数")
+
+# === 生命周期配置扩展 ===
+
+class ExtendedServiceLifecycleConfig(ServiceLifecycleConfig):
+ """扩展的服务生命周期配置模型"""
+ # 重试间隔配置
+ base_reconnect_delay: Optional[float] = Field(default=None, ge=0.5, le=10.0, description="基础重连延迟(秒),范围0.5-10.0")
+ max_reconnect_delay: Optional[float] = Field(default=None, ge=10.0, le=300.0, description="最大重连延迟(秒),范围10.0-300.0")
+
+ # 健康检查配置
+ health_check_interval: Optional[float] = Field(default=None, ge=5.0, le=300.0, description="健康检查间隔(秒),范围5.0-300.0")
+ health_check_timeout: Optional[float] = Field(default=None, ge=1.0, le=60.0, description="健康检查超时(秒),范围1.0-60.0")
+
+ # 性能监控配置
+ enable_performance_metrics: Optional[bool] = Field(default=None, description="是否启用性能指标收集")
+ metrics_retention_days: Optional[int] = Field(default=None, ge=1, le=365, description="指标保留天数,范围1-365")
+ long_retry_interval: Optional[float] = Field(default=None, ge=60.0, le=1800.0, description="长周期重试间隔(秒),范围60.0-1800.0")
+
+ # 心跳配置
+ normal_heartbeat_interval: Optional[float] = Field(default=None, ge=10.0, le=300.0, description="正常心跳间隔(秒),范围10.0-300.0")
+ warning_heartbeat_interval: Optional[float] = Field(default=None, ge=5.0, le=60.0, description="警告状态心跳间隔(秒),范围5.0-60.0")
+
+ # 超时配置
+ initialization_timeout: Optional[float] = Field(default=None, ge=5.0, le=120.0, description="初始化超时(秒),范围5.0-120.0")
+ disconnection_timeout: Optional[float] = Field(default=None, ge=1.0, le=60.0, description="断连超时(秒),范围1.0-60.0")
+
+# === 内容更新配置模型 ===
+class ContentUpdateConfig(BaseModel):
+ """服务内容更新配置模型"""
+ # 更新间隔
+ tools_update_interval: Optional[float] = Field(default=None, ge=60.0, le=3600.0, description="工具更新间隔(秒),范围60.0-3600.0")
+ resources_update_interval: Optional[float] = Field(default=None, ge=60.0, le=3600.0, description="资源更新间隔(秒),范围60.0-3600.0")
+ prompts_update_interval: Optional[float] = Field(default=None, ge=60.0, le=3600.0, description="提示词更新间隔(秒),范围60.0-3600.0")
+
+ # 批量处理配置
+ max_concurrent_updates: Optional[int] = Field(default=None, ge=1, le=10, description="最大并发更新数,范围1-10")
+ update_timeout: Optional[float] = Field(default=None, ge=10.0, le=120.0, description="单次更新超时(秒),范围10.0-120.0")
+
+ # 错误处理
+ max_consecutive_failures: Optional[int] = Field(default=None, ge=1, le=10, description="最大连续失败次数,范围1-10")
+ failure_backoff_multiplier: Optional[float] = Field(default=None, ge=1.0, le=5.0, description="失败退避倍数,范围1.0-5.0")
+
+ # === 新增:健康状态阈值配置 ===
+ healthy_response_threshold: Optional[float] = Field(default=None, ge=0.1, le=5.0, description="健康状态响应时间阈值(秒),范围0.1-5.0")
+ warning_response_threshold: Optional[float] = Field(default=None, ge=0.5, le=10.0, description="警告状态响应时间阈值(秒),范围0.5-10.0")
+ slow_response_threshold: Optional[float] = Field(default=None, ge=1.0, le=30.0, description="慢响应状态响应时间阈值(秒),范围1.0-30.0")
+
+ # === 新增:智能超时调整配置 ===
+ enable_adaptive_timeout: Optional[bool] = Field(default=None, description="是否启用智能超时调整")
+ adaptive_timeout_multiplier: Optional[float] = Field(default=None, ge=1.5, le=5.0, description="智能超时倍数,范围1.5-5.0")
+ response_time_history_size: Optional[int] = Field(default=None, ge=5, le=100, description="响应时间历史记录大小,范围5-100")
+
+
+# === 🆕 分页/排序/过滤增强模型 ===
+
+class EnhancedPaginationInfo(BaseModel):
+ """
+ 增强的分页信息(统一格式)
+
+ 无论是否使用分页参数,始终返回此结构。
+ 不使用分页时,limit 会等于 total,表示返回全部数据。
+ """
+ page: int = Field(..., description="当前页码(从1开始)")
+ limit: int = Field(..., description="每页数量")
+ total: int = Field(..., description="总记录数")
+ total_pages: int = Field(..., description="总页数")
+ has_next: bool = Field(..., description="是否有下一页")
+ has_prev: bool = Field(..., description="是否有上一页")
+
+
+class ListFilterInfo(BaseModel):
+ """列表过滤信息"""
+ status: Optional[str] = Field(None, description="状态过滤")
+ search: Optional[str] = Field(None, description="搜索关键词")
+ service_type: Optional[str] = Field(None, description="服务类型")
+
+
+class ListSortInfo(BaseModel):
+ """列表排序信息"""
+ by: str = Field(..., description="排序字段")
+ order: str = Field(..., description="排序方向: asc/desc")
+
+
+def create_enhanced_pagination_info(
+ page: Optional[int],
+ limit: Optional[int],
+ filtered_count: int
+) -> EnhancedPaginationInfo:
+ """
+ 创建增强的分页信息(统一格式)
+
+ Args:
+ page: 用户请求的页码(None 表示不分页)
+ limit: 用户请求的每页数量(None 表示不分页)
+ filtered_count: 过滤后的记录数
+
+ Returns:
+ EnhancedPaginationInfo: 统一格式的分页信息
+
+ Note:
+ - 如果不传分页参数(page 和 limit 都为 None),limit 自动设置为 filtered_count
+ - 这样前端可以统一处理响应格式,无需区分是否��页
+ """
+ # 不传分页参数时,返回全部数据
+ if page is None and limit is None:
+ return EnhancedPaginationInfo(
+ page=1,
+ limit=filtered_count, # limit 等于总数(返回全部)
+ total=filtered_count,
+ total_pages=1,
+ has_next=False,
+ has_prev=False
+ )
+
+ # 使用分页参数
+ page = page or 1
+ limit = limit or 20
+
+ # 计算总页数(向上取整)
+ total_pages = (filtered_count + limit - 1) // limit if limit > 0 else 0
+
+ # 计算当前页的范围
+ start = (page - 1) * limit
+ end = start + limit
+
+ return EnhancedPaginationInfo(
+ page=page,
+ limit=limit,
+ total=filtered_count,
+ total_pages=total_pages,
+ has_next=end < filtered_count, # 是否有下一页
+ has_prev=page > 1 # 是否有上一页
+ )
diff --git a/src/mcpstore/scripts/api_service_utils.py b/src/mcpstore/scripts/api_service_utils.py
new file mode 100644
index 00000000..29f9536b
--- /dev/null
+++ b/src/mcpstore/scripts/api_service_utils.py
@@ -0,0 +1,193 @@
+"""
+MCPStore API Service Utilities
+公共服务操作工具模块,用于消除重复代码
+"""
+
+import asyncio
+import logging
+from typing import Dict, Any, Optional
+
+from mcpstore import MCPStore
+from .api_exceptions import (
+ MCPStoreException, ErrorCode, error_monitor
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceOperationHelper:
+ """服务操作辅助类,提供通用的服务操作方法(分片文件已废弃)"""
+
+ # 分片文件已废弃:保留其他通用方法(如 get_service_details 等)
+
+
+
+
+ @staticmethod
+ async def get_service_details(
+ store: MCPStore,
+ service_name: str,
+ context_type: str = "store",
+ agent_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 获取服务详细信息的通用方法
+
+ Args:
+ store: MCPStore 实例
+ service_name: 服务名称
+ context_type: 上下文类型 ("store" 或 "agent")
+ agent_id: Agent ID(仅在 context_type 为 "agent" 时需要)
+ """
+ try:
+ # 获取上下文
+ if context_type == "store":
+ context = store.for_store()
+ elif context_type == "agent":
+ if not agent_id:
+ raise ValueError("agent_id is required for agent context")
+ context = store.for_agent(agent_id)
+ else:
+ raise ValueError(f"Invalid context_type: {context_type}")
+
+ # 获取服务配置
+ service_config = None
+ for service in context.services:
+ if service.name == service_name:
+ service_config = service
+ break
+
+ if not service_config:
+ raise MCPStoreException(
+ message=f"Service '{service_name}' not found",
+ error_code=ErrorCode.SERVICE_NOT_FOUND,
+ details={"service_name": service_name, "context_type": context_type}
+ )
+
+ # 获取工具列表
+ tools_info = []
+ if hasattr(context, '_tools') and context._tools:
+ for tool_name, tool_def in context._tools.items():
+ if tool_def.get('service') == service_name:
+ tools_info.append({
+ "name": tool_name,
+ "description": tool_def.get("description", ""),
+ "input_schema": tool_def.get("inputSchema", {})
+ })
+
+ # 构建服务详情
+ service_details = {
+ "name": service_config.name,
+ "status": "active" if hasattr(service_config, 'client') and service_config.client else "inactive",
+ "transport": service_config.config.get("transport", "unknown"),
+ "client_id": getattr(service_config, 'client_id', None),
+ "url": service_config.config.get("url"),
+ "command": service_config.config.get("command"),
+ "args": service_config.config.get("args"),
+ "env": service_config.config.get("env"),
+ "tool_count": len(tools_info),
+ "is_active": hasattr(service_config, 'client') and service_config.client is not None,
+ "config": service_config.config,
+ "tools": tools_info
+ }
+
+ # 添加生命周期信息 - 从 pykv 异步获取元数据
+ if hasattr(store, 'orchestrator') and store.orchestrator:
+ lifecycle_manager = store.orchestrator.lifecycle_manager
+ target_agent_id = agent_id or store.orchestrator.client_manager.global_agent_store_id
+
+ state = lifecycle_manager.get_service_state(target_agent_id, service_name)
+ # 从 pykv 异步获取元数据
+ metadata = await context.bridge_execute(
+ store.registry._service_state_service.get_service_metadata_async(
+ target_agent_id,
+ service_name
+ )
+ )
+
+ if state:
+ service_details["lifecycle"] = {
+ "consecutive_successes": metadata.consecutive_successes if metadata else 0,
+ "consecutive_failures": metadata.consecutive_failures if metadata else 0,
+ "last_ping_time": metadata.last_success_time.isoformat() if metadata and metadata.last_success_time else None,
+ "error_message": metadata.error_message if metadata else None,
+ "reconnect_attempts": metadata.reconnect_attempts if metadata else 0,
+ "state_entered_time": metadata.state_entered_time.isoformat() if metadata and metadata.state_entered_time else None
+ }
+
+ return service_details
+
+ except Exception as e:
+ error_monitor.record_error(e, {
+ "operation": "get_service_details",
+ "service_name": service_name,
+ "context_type": context_type,
+ "agent_id": agent_id
+ })
+ raise
+
+ @staticmethod
+ async def get_config_with_timeout(
+ context,
+ timeout: float = 30.0
+ ) -> Dict[str, Any]:
+ """
+ 带超时的配置获取方法
+
+ Args:
+ context: 上下文对象
+ timeout: 超时时间(秒)
+ """
+ try:
+ # 使用 asyncio.wait_for 实现超时控制
+ return await asyncio.wait_for(
+ context.bridge_execute(context.get_config_async()),
+ timeout=timeout
+ )
+ except asyncio.TimeoutError:
+ raise MCPStoreException(
+ message="Configuration retrieval timed out",
+ error_code=ErrorCode.CONFIG_ERROR,
+ details={"timeout": timeout, "operation": "get_config_async"}
+ )
+ except Exception as e:
+ raise MCPStoreException(
+ message=f"Failed to retrieve configuration: {str(e)}",
+ error_code=ErrorCode.CONFIG_ERROR,
+ details={"error": str(e)}
+ )
+
+ @staticmethod
+ async def update_config_with_timeout(
+ context,
+ config_data: Dict[str, Any],
+ timeout: float = 30.0
+ ) -> bool:
+ """
+ 带超时的配置更新方法
+
+ Args:
+ context: 上下文对象
+ config_data: 配置数据
+ timeout: 超时时间(秒)
+ """
+ try:
+ # 使用 asyncio.wait_for 实现超时控制
+ return await asyncio.wait_for(
+ context.bridge_execute(context.update_config_async(config_data)),
+ timeout=timeout
+ )
+ except asyncio.TimeoutError:
+ raise MCPStoreException(
+ message="Configuration update timed out",
+ error_code=ErrorCode.CONFIG_UPDATE_FAILED,
+ details={"timeout": timeout, "operation": "update_config_async"}
+ )
+ except Exception as e:
+ raise MCPStoreException(
+ message=f"Failed to update configuration: {str(e)}",
+ error_code=ErrorCode.CONFIG_UPDATE_FAILED,
+ details={"error": str(e)}
+ )
+
+
diff --git a/src/mcpstore/scripts/api_store.py b/src/mcpstore/scripts/api_store.py
new file mode 100644
index 00000000..ed4b3518
--- /dev/null
+++ b/src/mcpstore/scripts/api_store.py
@@ -0,0 +1,1009 @@
+"""
+MCPStore API - Store 级别路由
+定义所有 Store 作用域的 API 端点。
+"""
+
+from typing import Optional, Dict, Any, List, Union
+
+from fastapi import APIRouter, Request, Query, Body
+
+from mcpstore.core.models import (
+ APIResponse,
+ ErrorCode,
+ ResponseBuilder,
+ timed_response,
+)
+from .api_dependencies import get_store
+from .api_models import (
+ SimpleToolExecutionRequest
+)
+from .api_service_utils import (
+ ServiceOperationHelper
+)
+
+# Create Store-level router
+store_router = APIRouter()
+
+# === Store-level operations ===
+
+# Note: sync_services endpoint removed (v0.6.0)
+# Reason: File monitoring mechanism automates config sync, no manual trigger needed
+# Migration: Directly modify mcp.json file, system will auto-sync within 1 second
+
+@store_router.get("/for_store/sync_status", response_model=APIResponse)
+@timed_response
+async def store_sync_status():
+ """Get sync status information"""
+ store = get_store()
+ context = store.for_store()
+
+ if hasattr(store.orchestrator, 'sync_manager') and store.orchestrator.sync_manager:
+ status = store.orchestrator.sync_manager.get_sync_status()
+ return ResponseBuilder.success(
+ message="Sync status retrieved",
+ data=status
+ )
+ else:
+ return ResponseBuilder.success(
+ message="Sync manager not available",
+ data={
+ "is_running": False,
+ "reason": "sync_manager_not_initialized"
+ }
+ )
+
+@store_router.post("/for_store/add_service", response_model=APIResponse)
+@timed_response
+async def store_add_service(
+ payload: Union[Dict[str, Any], List[Dict[str, Any]], str] = Body(
+ ...,
+ description="服务配置,支持单个服务配置或包含 mcpServers 的字典,也可传入配置列表"
+ )
+):
+ """
+ Store 级别添加服务(必填 payload,不再支持空参数触发全量同步)
+
+ 支持模式:
+ 1. 直接传入单个服务配置(url/command 等)
+ 2. 传入包含 mcpServers 的字典(兼容 mcp.json 结构)
+ 3. 传入配置列表(一次注册多个服务)
+ 4. 传入 JSON 字符串配置(内部会解析)
+ """
+ store = get_store()
+
+ # 校验必填参数,拒绝空载
+ if payload is None:
+ return ResponseBuilder.error(
+ code=ErrorCode.MISSING_PARAMETER,
+ message="缺少必填参数 payload(服务配置)",
+ details={"expected": "服务配置对象或 mcpServers 字典"}
+ )
+ if isinstance(payload, (dict, list)) and not payload:
+ return ResponseBuilder.error(
+ code=ErrorCode.MISSING_PARAMETER,
+ message="服务配置不能为空",
+ details={"expected": "至少包含一个服务配置"}
+ )
+
+ # 添加服务
+ context = store.for_store()
+ try:
+ await context.bridge_execute(context.add_service_async(payload))
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_INITIALIZATION_FAILED,
+ message="服务注册失败",
+ details={"error": str(e)}
+ )
+
+ # 提取服务名用于响应
+ service_names: List[str] = []
+ if isinstance(payload, dict):
+ if "name" in payload:
+ service_names = [str(payload.get("name"))]
+ else:
+ mcp_servers = payload.get("mcpServers") if isinstance(payload, dict) else None
+ if isinstance(mcp_servers, dict):
+ service_names = list(mcp_servers.keys())
+ elif isinstance(payload, list):
+ service_names = [
+ str(item.get("name"))
+ for item in payload
+ if isinstance(item, dict) and item.get("name")
+ ]
+ else:
+ service_names = ["(字符串配置)"]
+
+ display_name = service_names or ["unknown"]
+
+ # 返回成功,附带服务基本信息
+ return ResponseBuilder.success(
+ message="服务添加请求已提交",
+ data={
+ "service_names": display_name,
+ "status": "initializing"
+ }
+ )
+
+@store_router.get("/for_store/list_services", response_model=APIResponse)
+@timed_response
+async def store_list_services(
+ # 分页参数(可选)
+ page: Optional[int] = Query(None, ge=1, description="页码(从1开始),不传则返回全部"),
+ limit: Optional[int] = Query(None, ge=1, le=1000, description="每页数量(1-1000),不传则返回全部"),
+
+ # 过滤参数(可选)
+ status: Optional[str] = Query(None, description="按状态过滤:active/ready/error/initializing"),
+ search: Optional[str] = Query(None, description="搜索服务名称(模糊匹配)"),
+ service_type: Optional[str] = Query(None, description="按类型过滤:sse/stdio"),
+
+ # 排序参数(可选)
+ sort_by: Optional[str] = Query(None, description="排序字段:name/status/tools_count"),
+ sort_order: Optional[str] = Query(None, description="排序方向:asc/desc,默认 asc")
+):
+ """
+ 获取 Store 级别服务列表(增强版 - 统一响应格式)
+
+ 响应格式说明:
+ - 始终返回包含 pagination 字段的统一格式
+ - 不传分页参数时,limit 自动等于 total(返回全部数据)
+ - 前端只需一套解析逻辑
+
+ 示例:
+
+ 1. 不传参数(返回全部):
+ GET /for_store/list_services
+ → 返回全部服务,pagination.limit = pagination.total
+
+ 2. 使用分页:
+ GET /for_store/list_services?page=1&limit=20
+ → 返回第 1 页,每页 20 条
+
+ 3. 搜索:
+ GET /for_store/list_services?search=weather
+ → 返回名称包含 "weather" 的所有服务
+
+ 4. 过滤 + 分页:
+ GET /for_store/list_services?status=error&page=1&limit=10
+ → 返回错误状态的服务,第 1 页,每页 10 条
+
+ 5. 排序:
+ GET /for_store/list_services?sort_by=status&sort_order=desc
+ → 按状态降序排列,返回全部
+ """
+ from .api_models import (
+ ListFilterInfo,
+ ListSortInfo,
+ create_enhanced_pagination_info
+ )
+
+ store = get_store()
+ context = store.for_store()
+
+ # 1. 获取所有服务(使用 async 版本)
+ all_services = await context.bridge_execute(context.list_services_async())
+ original_count = len(all_services)
+
+ # 2. 应用过滤
+ filtered_services = all_services
+
+ if status:
+ filtered_services = [
+ s for s in filtered_services
+ if s.get("status", "").lower() == status.lower()
+ ]
+
+ if search:
+ search_lower = search.lower()
+ filtered_services = [
+ s for s in filtered_services
+ if search_lower in s.get("name", "").lower()
+ ]
+
+ if service_type:
+ filtered_services = [
+ s for s in filtered_services
+ if s.get("type", "") == service_type
+ ]
+
+ filtered_count = len(filtered_services)
+
+ # 3. 应用排序
+ if sort_by:
+ reverse = (sort_order == "desc") if sort_order else False
+
+ if sort_by == "name":
+ filtered_services.sort(key=lambda s: s.get("name", ""), reverse=reverse)
+ elif sort_by == "status":
+ filtered_services.sort(key=lambda s: s.get("status", ""), reverse=reverse)
+ elif sort_by == "tools_count":
+ filtered_services.sort(key=lambda s: s.get("tools_count", 0) or 0, reverse=reverse)
+
+ # 4. 应用分页(如果有)
+ if page is not None or limit is not None:
+ page = page or 1
+ limit = limit or 20
+
+ start = (page - 1) * limit
+ end = start + limit
+ paginated_services = filtered_services[start:end]
+ else:
+ # 不分页,返回全部
+ paginated_services = filtered_services
+
+ # 5. 构造服务数据
+ def build_service_data(service) -> Dict[str, Any]:
+ """构造单个服务的数据"""
+ # service 已经是字典(从 StoreProxy.list_services 返回)
+ # 如果是对象,转换为字典访问
+ if isinstance(service, dict):
+ # 直接使用字典键访问
+ service_data = {
+ "name": service.get("name", ""),
+ "url": service.get("url", ""),
+ "command": service.get("command", ""),
+ "args": service.get("args", []),
+ "env": service.get("env", {}),
+ "working_dir": service.get("working_dir", ""),
+ "package_name": service.get("package_name", ""),
+ "keep_alive": service.get("keep_alive", False),
+ "type": service.get("type", "unknown"),
+ "status": service.get("status", "unknown"),
+ "tools_count": service.get("tools_count", 0) or service.get("tool_count", 0) or 0,
+ "last_check": None,
+ "client_id": service.get("client_id", ""),
+ }
+
+ # 处理 state_metadata(如果存在)
+ state_metadata = service.get("state_metadata")
+ if state_metadata and isinstance(state_metadata, dict):
+ last_ping_time = state_metadata.get("last_ping_time")
+ if last_ping_time:
+ service_data["last_check"] = last_ping_time if isinstance(last_ping_time, str) else None
+ else:
+ # 对象访问方式(向后兼容)
+ service_data = {
+ "name": service.name,
+ "url": service.url or "",
+ "command": service.command or "",
+ "args": service.args or [],
+ "env": service.env or {},
+ "working_dir": service.working_dir or "",
+ "package_name": service.package_name or "",
+ "keep_alive": service.keep_alive,
+ "type": service.transport_type.value if service.transport_type else "unknown",
+ "status": service.status.value if service.status else "unknown",
+ "tools_count": service.tool_count or 0,
+ "last_check": None,
+ "client_id": service.client_id or "",
+ }
+
+ if service.state_metadata:
+ service_data["last_check"] = (
+ service.state_metadata.last_ping_time.isoformat()
+ if service.state_metadata.last_ping_time else None
+ )
+
+ return service_data
+
+ services_data = [build_service_data(s) for s in paginated_services]
+
+ # 6. 创建统一的分页信息
+ pagination = create_enhanced_pagination_info(
+ page=page,
+ limit=limit,
+ filtered_count=filtered_count
+ )
+
+ # 7. 构造响应数据(统一格式)
+ response_data = {
+ "services": services_data,
+ "pagination": pagination.model_dump()
+ }
+
+ # 添加过滤信息(如果有)
+ if any([status, search, service_type]):
+ response_data["filters"] = ListFilterInfo(
+ status=status,
+ search=search,
+ service_type=service_type
+ ).model_dump(exclude_none=True)
+
+ # 添加排序信息(如果有)
+ if sort_by:
+ response_data["sort"] = ListSortInfo(
+ by=sort_by,
+ order=sort_order or "asc"
+ ).model_dump()
+
+ # 8. 返回统一格式的响应
+ message_parts = [f"Retrieved {len(services_data)} services"]
+
+ if filtered_count < original_count:
+ message_parts.append(f"(filtered from {original_count})")
+
+ if page is not None:
+ message_parts.append(f"(page {pagination.page} of {pagination.total_pages})")
+
+ return ResponseBuilder.success(
+ message=" ".join(message_parts),
+ data=response_data
+ )
+
+@store_router.post("/for_store/reset_service", response_model=APIResponse)
+@timed_response
+async def store_reset_service(request: Request):
+ """Store 级别重置服务状态
+
+ 重置已存在服务的状态到 INITIALIZING,清除所有错误计数和历史记录
+ """
+ body = await request.json()
+
+ store = get_store()
+ context = store.for_store()
+
+ # 提取参数
+ identifier = body.get("identifier")
+ client_id = body.get("client_id")
+ service_name = body.get("service_name")
+
+ used_identifier = service_name or identifier or client_id
+
+ if not used_identifier:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Missing service identifier",
+ field="service_name"
+ )
+
+ agent_id = store.client_manager.global_agent_store_id
+ registry = store.registry
+
+ # 尝试解析最终的 service_name(Store 级别只处理全局服务名/确定性 client_id)
+ resolved_service_name = None
+
+ # 优先显式 service_name
+ if service_name:
+ resolved_service_name = service_name
+ else:
+ raw = identifier or client_id
+ if raw:
+ try:
+ from mcpstore.core.utils.id_generator import ClientIDGenerator
+
+ if ClientIDGenerator.is_deterministic_format(raw):
+ parsed = ClientIDGenerator.parse_client_id(raw)
+ if parsed.get("type") == "store":
+ resolved_service_name = parsed.get("service_name")
+ else:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Client ID type is not supported for store reset",
+ field="client_id"
+ )
+ except Exception:
+ # 解析失败时退化为直接视为服务名(与原实现中将 identifier 视为名称的行为对齐)
+ resolved_service_name = raw
+
+ if not resolved_service_name:
+ resolved_service_name = used_identifier
+
+ # 校验服务是否存在(使用异步 API)
+ service_exists = await context.bridge_execute(
+ registry.has_service_async(agent_id, resolved_service_name)
+ )
+ if not service_exists:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Service '{resolved_service_name}' not found",
+ field="service_name"
+ )
+
+ app_service = store.container.service_application_service
+ ok = await context.bridge_execute(
+ app_service.reset_service(
+ agent_id=agent_id,
+ service_name=resolved_service_name,
+ wait_timeout=0.0,
+ )
+ )
+
+ if not ok:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_OPERATION_FAILED,
+ message=f"Failed to reset service '{resolved_service_name}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{resolved_service_name}' reset successfully",
+ data={"service_name": resolved_service_name, "status": "initializing"}
+ )
+
+@store_router.get("/for_store/list_tools", response_model=APIResponse)
+@timed_response
+async def store_list_tools(
+ # 分页参数(可选)
+ page: Optional[int] = Query(None, ge=1, description="页码(从1开始),不传则返回全部"),
+ limit: Optional[int] = Query(None, ge=1, le=1000, description="每页数量(1-1000),不传则返回全部"),
+
+ # 过滤参数(可选)
+ search: Optional[str] = Query(None, description="搜索工具名称或描述(模糊匹配)"),
+ service_name: Optional[str] = Query(None, description="按服务名称过滤"),
+
+ # 排序参数(可选)
+ sort_by: Optional[str] = Query(None, description="排序字段:name/service"),
+ sort_order: Optional[str] = Query(None, description="排序方向:asc/desc,默认 asc")
+):
+ """
+ 获取 Store 级别工具列表(增强版 - 统一响应格式)
+
+ 响应格式说明:
+ - 始终返回包含 pagination 字段的统一格式
+ - 不传分页参数时,limit 自动等于 total(返回全部数据)
+ - 前端只需一套解析逻辑
+
+ 示例:
+
+ 1. 不传参数(返回全部):
+ GET /for_store/list_tools
+ → 返回全部工具,pagination.limit = pagination.total
+
+ 2. 使用分页:
+ GET /for_store/list_tools?page=1&limit=20
+ → 返回第 1 页,每页 20 条
+
+ 3. 搜索:
+ GET /for_store/list_tools?search=weather
+ → 返回名称或描述包含 "weather" 的所有工具
+
+ 4. 按服务过滤:
+ GET /for_store/list_tools?service_name=mcpstore-wiki
+ → 返回指定服务的所有工具
+
+ 5. 排序:
+ GET /for_store/list_tools?sort_by=name&sort_order=asc
+ → 按名称升序排列,返回全部
+ """
+ from .api_models import (
+ ListSortInfo,
+ create_enhanced_pagination_info
+ )
+
+ store = get_store()
+ context = store.for_store()
+
+ # 1. 获取所有工具(使用 async 版本)
+ all_tools = await context.bridge_execute(context.list_tools_async())
+ original_count = len(all_tools)
+
+ # 2. 应用过滤
+ filtered_tools = all_tools
+
+ if search:
+ search_lower = search.lower()
+ filtered_tools = [
+ t for t in filtered_tools
+ if search_lower in (t.get("name", "") if isinstance(t, dict) else t.name).lower() or
+ search_lower in (t.get("description", "") if isinstance(t, dict) else (t.description or "")).lower()
+ ]
+
+ if service_name:
+ filtered_tools = [
+ t for t in filtered_tools
+ if (t.get('service_name', 'unknown') if isinstance(t, dict) else getattr(t, 'service_name', 'unknown')) == service_name
+ ]
+
+ filtered_count = len(filtered_tools)
+
+ # 3. 应用排序
+ if sort_by:
+ reverse = (sort_order == "desc") if sort_order else False
+
+ if sort_by == "name":
+ filtered_tools.sort(key=lambda t: t.get("name", "") if isinstance(t, dict) else t.name, reverse=reverse)
+ elif sort_by == "service":
+ filtered_tools.sort(
+ key=lambda t: t.get('service_name', 'unknown') if isinstance(t, dict) else getattr(t, 'service_name', 'unknown'),
+ reverse=reverse
+ )
+
+ # 4. 应用分页(如果有)
+ if page is not None or limit is not None:
+ page = page or 1
+ limit = limit or 20
+
+ start = (page - 1) * limit
+ end = start + limit
+ paginated_tools = filtered_tools[start:end]
+ else:
+ # 不分页,返回全部
+ paginated_tools = filtered_tools
+
+ # 5. 构造工具数据
+ def build_tool_data(tool) -> Dict[str, Any]:
+ """构造单个工具的数据(兼容字典和对象)"""
+ if isinstance(tool, dict):
+ return {
+ "name": tool.get("name", ""),
+ "service": tool.get('service_name', 'unknown'),
+ "description": tool.get("description", ""),
+ "input_schema": tool.get("inputSchema", {}) or tool.get("input_schema", {})
+ }
+ else:
+ return {
+ "name": tool.name,
+ "service": getattr(tool, 'service_name', 'unknown'),
+ "description": tool.description or "",
+ "input_schema": tool.inputSchema if hasattr(tool, 'inputSchema') else {}
+ }
+
+ tools_data = [build_tool_data(t) for t in paginated_tools]
+
+ # 6. 创建统一的分页信息
+ pagination = create_enhanced_pagination_info(
+ page=page,
+ limit=limit,
+ filtered_count=filtered_count
+ )
+
+ # 7. 构造响应数据(统一格式)
+ response_data = {
+ "tools": tools_data,
+ "pagination": pagination.model_dump()
+ }
+
+ # 添加过滤信息(如果有)
+ if any([search, service_name]):
+ response_data["filters"] = {
+ "search": search,
+ "service_name": service_name
+ }
+ # 移除 None 值
+ response_data["filters"] = {k: v for k, v in response_data["filters"].items() if v is not None}
+
+ # 添加排序信息(如果有)
+ if sort_by:
+ response_data["sort"] = ListSortInfo(
+ by=sort_by,
+ order=sort_order or "asc"
+ ).model_dump()
+
+ # 8. 返回统一格式的响应
+ message_parts = [f"Retrieved {len(tools_data)} tools"]
+
+ if filtered_count < original_count:
+ message_parts.append(f"(filtered from {original_count})")
+
+ if page is not None:
+ message_parts.append(f"(page {pagination.page} of {pagination.total_pages})")
+
+ return ResponseBuilder.success(
+ message=" ".join(message_parts),
+ data=response_data
+ )
+
+@store_router.get("/for_store/check_services", response_model=APIResponse)
+@timed_response
+async def store_check_services():
+ """Store 级别批量健康检查"""
+ store = get_store()
+ context = store.for_store()
+ health_status = await context.bridge_execute(context.check_services_async())
+
+ return ResponseBuilder.success(
+ message=f"Health check completed for {len(health_status.get('services', []))} services",
+ data=health_status
+ )
+
+@store_router.get("/for_store/list_agents", response_model=APIResponse)
+@timed_response
+async def store_list_agents():
+ """Store 级列出所有 Agents 概要信息(增强版,无分页)
+
+ 返回统一结构,包含 agents 明细与汇总 summary。
+
+ [架构说明] 使用异步方法 list_agents_async() 避免在 FastAPI 事件循环中触发 AOB 冲突
+ """
+ store = get_store()
+ # 使用异步方法,避免在 FastAPI 事件循环中调用同步方法触发 AOB 冲突
+ context = store.for_store()
+ agents = await context.bridge_execute(context.list_agents_async())
+
+ total_agents = len(agents)
+ total_services = sum(int(a.get("service_count", 0)) for a in agents)
+ total_tools = sum(int(a.get("tool_count", 0)) for a in agents)
+ healthy_agents = sum(1 for a in agents if int(a.get("healthy_services", 0)) > 0)
+ unhealthy_agents = total_agents - healthy_agents
+
+ response_data = {
+ "agents": agents,
+ "summary": {
+ "total_agents": total_agents,
+ "total_services": total_services,
+ "total_tools": total_tools,
+ "healthy_agents": healthy_agents,
+ "unhealthy_agents": unhealthy_agents
+ }
+ }
+
+ return ResponseBuilder.success(
+ message=f"Retrieved {total_agents} agents",
+ data=response_data
+ )
+
+@store_router.post("/for_store/call_tool", response_model=APIResponse)
+@timed_response
+async def store_call_tool(request: SimpleToolExecutionRequest):
+ """Store 级别工具执行"""
+ store = get_store()
+ context = store.for_store()
+ result = await context.bridge_execute(
+ context.call_tool_async(request.tool_name, request.args)
+ )
+
+ # 规范化 CallToolResult 或其它返回值为可序列化结构
+ def _normalize_result(res):
+ try:
+ # FastMCP CallToolResult: 有 content/is_error 字段
+ if hasattr(res, 'content'):
+ items = []
+ for c in getattr(res, 'content', []) or []:
+ try:
+ if isinstance(c, dict):
+ items.append(c)
+ elif hasattr(c, 'type') and hasattr(c, 'text'):
+ items.append({"type": getattr(c, 'type', 'text'), "text": getattr(c, 'text', '')})
+ elif hasattr(c, 'type') and hasattr(c, 'uri'):
+ items.append({"type": getattr(c, 'type', 'uri'), "uri": getattr(c, 'uri', '')})
+ else:
+ items.append(str(c))
+ except Exception:
+ items.append(str(c))
+ return {"content": items, "is_error": bool(getattr(res, 'is_error', False))}
+ # 已是 Dict/List
+ if isinstance(res, (dict, list)):
+ return res
+ # 其它类型转字符串
+ return {"result": str(res)}
+ except Exception:
+ return {"result": str(res)}
+
+ normalized = _normalize_result(result)
+
+ return ResponseBuilder.success(
+ message=f"Tool '{request.tool_name}' executed successfully",
+ data=normalized
+ )
+
+# Deleted POST /for_store/get_service_info (v0.6.0)
+# Please use GET /for_store/service_info/{service_name} instead (RESTful standard)
+
+@store_router.put("/for_store/update_service/{service_name}", response_model=APIResponse)
+@timed_response
+async def store_update_service(service_name: str, request: Request):
+ """Store 级别更新服务配置"""
+ body = await request.json()
+
+ store = get_store()
+ context = store.for_store()
+ result = await context.bridge_execute(
+ context.update_service_async(service_name, body)
+ )
+
+ if not result:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Failed to update service '{service_name}'",
+ field="service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' updated successfully",
+ data={"service_name": service_name, "updated_fields": list(body.keys())}
+ )
+
+@store_router.delete("/for_store/delete_service/{service_name}", response_model=APIResponse)
+@timed_response
+async def store_delete_service(service_name: str):
+ """Store 级别删除服务"""
+ store = get_store()
+ context = store.for_store()
+ result = await context.bridge_execute(
+ context.delete_service_async(service_name)
+ )
+
+ if not result:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Failed to delete service '{service_name}'",
+ field="service_name",
+ details={"service_name": service_name}
+ )
+
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' deleted successfully",
+ data={
+ "service_name": service_name,
+ "deleted_at": ResponseBuilder._get_timestamp()
+ }
+ )
+
+@store_router.post("/for_store/disconnect_service", response_model=APIResponse)
+@timed_response
+async def store_disconnect_service(request: Request):
+ """Store 级别断开服务(生命周期断链,不修改配置)
+
+ Body 示例:
+ {
+ "service_name": "remote-demo",
+ "reason": "user_requested"
+ }
+ """
+ body = await request.json()
+ service_name = body.get("service_name") or body.get("name")
+ reason = body.get("reason", "user_requested")
+
+ if not service_name:
+ return ResponseBuilder.error(
+ code=ErrorCode.VALIDATION_ERROR,
+ message="Missing service_name"
+ )
+
+ store = get_store()
+ context = store.for_store()
+
+ try:
+ ok = await context.bridge_execute(
+ context.disconnect_service_async(service_name, reason=reason)
+ )
+ if ok:
+ return ResponseBuilder.success(
+ message=f"Service '{service_name}' disconnected",
+ data={"service_name": service_name, "status": "disconnected"}
+ )
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_OPERATION_FAILED,
+ message=f"Failed to disconnect service '{service_name}'",
+ details={"service_name": service_name}
+ )
+ except Exception as e:
+ return ResponseBuilder.error(
+ code=ErrorCode.INTERNAL_ERROR,
+ message=f"Failed to disconnect service '{service_name}': {e}",
+ details={"service_name": service_name}
+ )
+
+@store_router.get("/for_store/show_config", response_model=APIResponse)
+@timed_response
+async def store_show_config():
+ """获取运行时配置和服务映射关系
+
+ 返回格式与 mcp.json 一致:{"mcpServers": {...}}
+ 服务名称使用全局名称(Store 添加的服务使用原始名称,Agent 添加的服务使用 name_byagent_agentId 格式)
+ """
+ store = get_store()
+ context = store.for_store()
+ config_data = await context.bridge_execute(context.show_config_async())
+
+ # 检查是否有错误
+ if "error" in config_data:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=config_data["error"],
+ details=config_data
+ )
+
+ return ResponseBuilder.success(
+ message="Retrieved service configuration",
+ data=config_data
+ )
+
+@store_router.delete("/for_store/delete_config/{client_id_or_service_name}", response_model=APIResponse)
+@timed_response
+async def store_delete_config(client_id_or_service_name: str):
+ """Store 级别删除服务配置"""
+ store = get_store()
+ context = store.for_store()
+ result = await context.bridge_execute(
+ context.delete_config_async(client_id_or_service_name)
+ )
+
+ if result.get("success"):
+ return ResponseBuilder.success(
+ message=result.get("message", "Configuration deleted successfully"),
+ data=result
+ )
+ else:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=result.get("error", "Failed to delete configuration"),
+ details=result
+ )
+
+@store_router.put("/for_store/update_config/{client_id_or_service_name}", response_model=APIResponse)
+@timed_response
+async def store_update_config(client_id_or_service_name: str, new_config: dict):
+ """Store 级别更新服务配置"""
+ store = get_store()
+ context = store.for_store()
+
+ # 使用带超时的配置更新方法
+ success = await ServiceOperationHelper.update_config_with_timeout(
+ context,
+ new_config,
+ timeout=30.0
+ )
+
+ if not success:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message=f"Failed to update configuration for {client_id_or_service_name}",
+ field="client_id_or_service_name"
+ )
+
+ return ResponseBuilder.success(
+ message=f"Configuration updated for {client_id_or_service_name}",
+ data={"identifier": client_id_or_service_name, "updated": True}
+ )
+
+@store_router.post("/for_store/reset_config", response_model=APIResponse)
+@timed_response
+async def store_reset_config():
+ """重置配置(缓存+文件全量重置)
+
+ 清空所有 pykv 缓存数据和 mcp.json 文件。
+ 相当于批量执行 delete_service 操作。
+
+ 清理内容:
+ - pykv 实体层:services, tools
+ - pykv 关系层:agent_services, service_tools
+ - pykv 状态层:service_status, service_metadata
+ - mcp.json 文件
+
+ [警告] 此操作不可逆,请谨慎使用
+ """
+ store = get_store()
+ context = store.for_store()
+ success = await context.bridge_execute(context.reset_config_async())
+
+ if not success:
+ return ResponseBuilder.error(
+ code=ErrorCode.CONFIGURATION_ERROR,
+ message="Failed to reset configuration"
+ )
+
+ return ResponseBuilder.success(
+ message="All configuration reset successfully",
+ data={"reset": True}
+ )
+
+# Removed shard-file reset APIs (client_services.json / agent_clients.json) in single-source mode
+
+@store_router.get("/for_store/setup_config", response_model=APIResponse)
+@timed_response
+async def store_setup_config():
+ """获取启动时的配置快照(在 MCPStore.setup_store 阶段记录)"""
+ store = get_store()
+ context = store.for_store()
+ setup_snapshot = context.setup_config()
+
+ return ResponseBuilder.success(
+ message="Setup configuration snapshot retrieved",
+ data=setup_snapshot
+ )
+
+# === Store 级别统计和监控 ===
+
+@store_router.get("/for_store/tool_records", response_model=APIResponse)
+@timed_response
+async def get_store_tool_records(limit: int = 50):
+ """获取Store级别的工具执行记录"""
+ store = get_store()
+ context = store.for_store()
+ records_data = await context.bridge_execute(
+ context.get_tool_records_async(limit)
+ )
+
+ # 简化返回结构
+ return ResponseBuilder.success(
+ message=f"Retrieved {len(records_data.get('executions', []))} tool execution records",
+ data=records_data
+ )
+
+@store_router.get("/for_store/show_mcpjson", response_model=APIResponse)
+@timed_response
+async def store_show_mcpjson():
+ """获取 mcp.json 配置文件的原始内容"""
+ store = get_store()
+ mcpjson = store.show_mcpjson()
+
+ return ResponseBuilder.success(
+ message="MCP JSON content retrieved",
+ data=mcpjson
+ )
+
+# === 服务详情相关 API ===
+
+@store_router.get("/for_store/service_info/{service_name}", response_model=APIResponse)
+@timed_response
+async def store_get_service_info_detailed(service_name: str):
+ """获取服务详细信息"""
+ store = get_store()
+ context = store.for_store()
+
+ # 查找服务(使用 async 版本)
+ all_services = await context.bridge_execute(context.list_services_async())
+ service = None
+ for s in all_services:
+ s_name = s.get("name") if isinstance(s, dict) else s.name
+ if s_name == service_name:
+ service = s
+ break
+
+ if not service:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Service '{service_name}' not found",
+ field="service_name"
+ )
+
+ # 构建简化的服务信息(兼容字典和对象)
+ if isinstance(service, dict):
+ service_info = {
+ "name": service.get("name", ""),
+ "status": service.get("status", "unknown"),
+ "type": service.get("type", "unknown"),
+ "client_id": service.get("client_id", ""),
+ "url": service.get("url", ""),
+ "tools_count": service.get("tools_count", 0) or service.get("tool_count", 0) or 0
+ }
+ else:
+ service_info = {
+ "name": service.name,
+ "status": service.status.value if service.status else "unknown",
+ "type": service.transport_type.value if service.transport_type else "unknown",
+ "client_id": service.client_id or "",
+ "url": service.url or "",
+ "tools_count": service.tool_count or 0
+ }
+
+ return ResponseBuilder.success(
+ message=f"Service info retrieved for '{service_name}'",
+ data=service_info
+ )
+
+@store_router.get("/for_store/service_status/{service_name}", response_model=APIResponse)
+@timed_response
+async def store_get_service_status(service_name: str):
+ """获取服务状态(轻量级,纯缓存读取)"""
+ store = get_store()
+ context = store.for_store()
+ agent_id = store.client_manager.global_agent_store_id
+
+ # 先按 Registry 视角检查服务是否存在(使用异步 API)
+ service_exists = await context.bridge_execute(
+ store.registry.has_service_async(agent_id, service_name)
+ )
+ if not service_exists:
+ return ResponseBuilder.error(
+ code=ErrorCode.SERVICE_NOT_FOUND,
+ message=f"Service '{service_name}' not found",
+ field="service_name"
+ )
+
+ app_service = store.container.service_application_service
+ status = await context.bridge_execute(
+ app_service.get_service_status(agent_id=agent_id, service_name=service_name)
+ )
+
+ status_info = {
+ "name": service_name,
+ "status": status.get("status", "unknown"),
+ "client_id": status.get("client_id", "") or "",
+ }
+
+ return ResponseBuilder.success(
+ message=f"Service status retrieved for '{service_name}'",
+ data=status_info
+ )
diff --git a/src/mcpstore/scripts/app.py b/src/mcpstore/scripts/app.py
index 0c002848..6187c18c 100644
--- a/src/mcpstore/scripts/app.py
+++ b/src/mcpstore/scripts/app.py
@@ -1,168 +1,28 @@
-"""
-MCPStore API 服务
-提供 HTTP API 服务入口
-"""
-
-import logging
-import os
-import sys
-import time
-import uuid
-import json # for pretty printing
-from fastapi import Request
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from fastapi import FastAPI
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.exceptions import RequestValidationError
-
-from mcpstore.core.store import MCPStore
-from mcpstore.core.orchestrator import MCPOrchestrator
-from mcpstore.core.registry import ServiceRegistry
-from mcpstore.plugins.json_mcp import MCPConfig
-from mcpstore.core.client_manager import ClientManager
-from mcpstore.core.session_manager import SessionManager
-from mcpstore.core.models.service import (
- RegisterRequestUnion, JsonRegistrationResponse, JsonUpdateRequest, JsonConfigResponse,
- ServiceInfo, ServicesResponse, TransportType, ServiceInfoResponse,
- ServiceRegistrationResult
-)
-from mcpstore.core.models.client import ClientRegistrationResponse
-from mcpstore.core.models.tool import (
- ToolExecutionResponse, ToolInfo, ToolsResponse, ToolExecutionRequest
-)
-from mcpstore.scripts.api import handle_exceptions
-from mcpstore.scripts.deps import app_state
-from typing import Callable
-from starlette.middleware.base import BaseHTTPMiddleware
-from .api import router
-
-# 配置日志
-logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
-)
-logger = logging.getLogger(__name__)
-logger.info("【第8步】Uvicorn 正在导入 app.py 文件。")
-
-async def lifespan(app: FastAPI):
- logger.info("【第10步】FastAPI 的 lifespan 已启动,开始初始化核心组件。")
-
- config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "mcp.json")
- mcp_config_handler = MCPConfig(config_path)
- logger.info(f" - MCPConfig 实例已创建,配置文件路径: {config_path}")
-
- config = mcp_config_handler.load_config()
- logger.info(" - 配置文件 mcp.json 已加载。")
- logger.info(f" - 加载的配置内容: \n{json.dumps(config, indent=2, ensure_ascii=False)}")
-
- registry = ServiceRegistry()
- logger.info(" - ServiceRegistry 实例已创建。")
-
- orchestrator = MCPOrchestrator(config=config, registry=registry)
- logger.info(" - MCPOrchestrator 实例已创建。")
-
- store = MCPStore(orchestrator=orchestrator, config=mcp_config_handler)
- logger.info(" - McpStore 实例已创建,聚合了所有核心组件。")
- logger.info("【第11步】所有核心组件的唯一实例已创建完毕。")
-
- logger.info(" - 准备调用 orchestrator.setup()")
- await orchestrator.setup()
- logger.info(" - orchestrator.setup() 已完成。")
-
- # logger.info(" - 准备调用 orchestrator.start_monitoring()")
- # await orchestrator.start_monitoring()
- # logger.info(" - orchestrator.start_monitoring() 已完成,后台健康检查等任务已启动。")
-
- # logger.info(" - 准备调用 orchestrator.register_json_services(),注册 mcp.json 中的服务。")
- # registration_results = await orchestrator.register_json_services(config, client_id="main_client")
- # logger.info(" - orchestrator.register_json_services() 已完成。")
- # logger.info(f" - 服务注册结果: \n{json.dumps(registration_results, indent=2, ensure_ascii=False)}")
-
- app_state["store"] = store
- logger.info(" - 唯一的 McpStore 实例已存入 app_state。")
- logger.info("【第12步】应用启动流程 (lifespan) 即将完成,准备移交控制权。")
-
- try:
- yield
- logger.info("Lifespan 正常结束,应用即将关闭。")
- finally:
- logger.info("Application shutdown: Cleaning up resources...")
- orch = app_state.get("orchestrator")
- if orch:
- await orch.stop_main_client()
- await orch.cleanup()
- app_state.clear()
- logger.info("Application shutdown complete.")
-
-# 创建应用实例
-app = FastAPI(
- title="MCPStore API",
- description="MCPStore HTTP API Service",
- version="0.1.0",
- lifespan=lifespan
-)
-logger.info("【第9步】FastAPI 应用实例 'app' 已创建。")
-
-# 配置CORS
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# 注册路由
-app.include_router(router)
-
-# 注册异常处理
-@app.exception_handler(RequestValidationError)
-async def validation_exception_handler(request: Request, exc: RequestValidationError):
- errors = exc.errors()
- error_messages = []
- for error in errors:
- loc = " -> ".join([str(l) for l in error["loc"] if l != "body"])
- msg = error["msg"]
- error_messages.append(f"{loc}: {msg}")
- return {
- "success": False,
- "message": "Validation error",
- "data": error_messages
- }
-
-# 添加请求日志中间件
-@app.middleware("http")
-async def log_requests(request: Request, call_next):
- """
- Middleware to log incoming requests, processing time, and status.
- """
- request_id = str(uuid.uuid4())
- logger.info(f"Request received - ID: {request_id}, Method: {request.method}, Path: {request.url.path}")
- start_time = time.time()
-
- try:
- response = await call_next(request)
- process_time = (time.time() - start_time) * 1000
- logger.info(
- f"Request finished - ID: {request_id}, "
- f"Status: {response.status_code}, Duration: {process_time:.2f}ms"
- )
- return response
- except Exception as e:
- process_time = (time.time() - start_time) * 1000
- logger.error(
- f"Request failed - ID: {request_id}, "
- f"Error: {e}, Duration: {process_time:.2f}ms",
- exc_info=True
- )
- raise
-
-@app.on_event("startup")
-async def startup():
- """应用启动时的初始化"""
- logger.info("MCPStore API service starting up...")
-
-@app.on_event("shutdown")
-async def shutdown():
- """应用关闭时的清理"""
- logger.info("MCPStore API service shutting down...")
+"""
+MCPStore API 服务 - 改进版
+支持 CLI 启动时的 URL 前缀配置
+"""
+
+import logging
+
+# 导入应用工厂
+from .api_app import create_app
+
+# 配置日志
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# 🆕 URL 前缀配置(不再使用环境变量)
+url_prefix = ""
+
+if url_prefix:
+ logger.info(f"Creating app with URL prefix: {url_prefix}")
+else:
+ logger.info("Creating app without URL prefix")
+
+# 创建应用实例(CLI 启动时使用)
+# store=None 表示使用默认配置
+app = create_app(store=None, url_prefix=url_prefix)
diff --git a/src/mcpstore/scripts/deps.py b/src/mcpstore/scripts/deps.py
deleted file mode 100644
index 729877c3..00000000
--- a/src/mcpstore/scripts/deps.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""
-全局应用状态和依赖项
-"""
-from typing import Dict, Any
-
-# 全局应用状态
-app_state: Dict[str, Any] = {}
diff --git a/src/mcpstore/utils/watchdog/__init__.py b/src/mcpstore/utils/watchdog/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/mcpstore/utils/watchdog/events.py b/src/mcpstore/utils/watchdog/events.py
new file mode 100644
index 00000000..86ae3b97
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/events.py
@@ -0,0 +1,542 @@
+""":module: watchdog.events
+:synopsis: File system events and event handlers.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+Event Classes
+-------------
+.. autoclass:: FileSystemEvent
+ :members:
+ :show-inheritance:
+ :inherited-members:
+
+.. autoclass:: FileSystemMovedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileMovedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: DirMovedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileModifiedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: DirModifiedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileCreatedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileClosedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileClosedNoWriteEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileOpenedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: DirCreatedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: FileDeletedEvent
+ :members:
+ :show-inheritance:
+
+.. autoclass:: DirDeletedEvent
+ :members:
+ :show-inheritance:
+
+
+Event Handler Classes
+---------------------
+.. autoclass:: FileSystemEventHandler
+ :members:
+ :show-inheritance:
+
+.. autoclass:: PatternMatchingEventHandler
+ :members:
+ :show-inheritance:
+
+.. autoclass:: RegexMatchingEventHandler
+ :members:
+ :show-inheritance:
+
+.. autoclass:: LoggingEventHandler
+ :members:
+ :show-inheritance:
+
+"""
+
+from __future__ import annotations
+
+import logging
+import os.path
+import re
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.utils.patterns import match_any_paths
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+EVENT_TYPE_MOVED = "moved"
+EVENT_TYPE_DELETED = "deleted"
+EVENT_TYPE_CREATED = "created"
+EVENT_TYPE_MODIFIED = "modified"
+EVENT_TYPE_CLOSED = "closed"
+EVENT_TYPE_CLOSED_NO_WRITE = "closed_no_write"
+EVENT_TYPE_OPENED = "opened"
+
+
+@dataclass(unsafe_hash=True)
+class FileSystemEvent:
+ """Immutable type that represents a file system event that is triggered
+ when a change occurs on the monitored file system.
+
+ All FileSystemEvent objects are required to be immutable and hence
+ can be used as keys in dictionaries or be added to sets.
+ """
+
+ src_path: bytes | str
+ dest_path: bytes | str = ""
+ event_type: str = field(default="", init=False)
+ is_directory: bool = field(default=False, init=False)
+
+ """
+ True if event was synthesized; False otherwise.
+ These are events that weren't actually broadcast by the OS, but
+ are presumed to have happened based on other, actual events.
+ """
+ is_synthetic: bool = field(default=False)
+
+
+class FileSystemMovedEvent(FileSystemEvent):
+ """File system event representing any kind of file system movement."""
+
+ event_type = EVENT_TYPE_MOVED
+
+
+# File events.
+
+
+class FileDeletedEvent(FileSystemEvent):
+ """File system event representing file deletion on the file system."""
+
+ event_type = EVENT_TYPE_DELETED
+
+
+class FileModifiedEvent(FileSystemEvent):
+ """File system event representing file modification on the file system."""
+
+ event_type = EVENT_TYPE_MODIFIED
+
+
+class FileCreatedEvent(FileSystemEvent):
+ """File system event representing file creation on the file system."""
+
+ event_type = EVENT_TYPE_CREATED
+
+
+class FileMovedEvent(FileSystemMovedEvent):
+ """File system event representing file movement on the file system."""
+
+
+class FileClosedEvent(FileSystemEvent):
+ """File system event representing file close on the file system."""
+
+ event_type = EVENT_TYPE_CLOSED
+
+
+class FileClosedNoWriteEvent(FileSystemEvent):
+ """File system event representing an unmodified file close on the file system."""
+
+ event_type = EVENT_TYPE_CLOSED_NO_WRITE
+
+
+class FileOpenedEvent(FileSystemEvent):
+ """File system event representing file close on the file system."""
+
+ event_type = EVENT_TYPE_OPENED
+
+
+# Directory events.
+
+
+class DirDeletedEvent(FileSystemEvent):
+ """File system event representing directory deletion on the file system."""
+
+ event_type = EVENT_TYPE_DELETED
+ is_directory = True
+
+
+class DirModifiedEvent(FileSystemEvent):
+ """File system event representing directory modification on the file system."""
+
+ event_type = EVENT_TYPE_MODIFIED
+ is_directory = True
+
+
+class DirCreatedEvent(FileSystemEvent):
+ """File system event representing directory creation on the file system."""
+
+ event_type = EVENT_TYPE_CREATED
+ is_directory = True
+
+
+class DirMovedEvent(FileSystemMovedEvent):
+ """File system event representing directory movement on the file system."""
+
+ is_directory = True
+
+
+class FileSystemEventHandler:
+ """Base file system event handler that you can override methods from."""
+
+ def dispatch(self, event: FileSystemEvent) -> None:
+ """Dispatches events to the appropriate methods.
+
+ :param event:
+ The event object representing the file system event.
+ :type event:
+ :class:`FileSystemEvent`
+ """
+ self.on_any_event(event)
+ getattr(self, f"on_{event.event_type}")(event)
+
+ def on_any_event(self, event: FileSystemEvent) -> None:
+ """Catch-all event handler.
+
+ :param event:
+ The event object representing the file system event.
+ :type event:
+ :class:`FileSystemEvent`
+ """
+
+ def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
+ """Called when a file or a directory is moved or renamed.
+
+ :param event:
+ Event representing file/directory movement.
+ :type event:
+ :class:`DirMovedEvent` or :class:`FileMovedEvent`
+ """
+
+ def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
+ """Called when a file or directory is created.
+
+ :param event:
+ Event representing file/directory creation.
+ :type event:
+ :class:`DirCreatedEvent` or :class:`FileCreatedEvent`
+ """
+
+ def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
+ """Called when a file or directory is deleted.
+
+ :param event:
+ Event representing file/directory deletion.
+ :type event:
+ :class:`DirDeletedEvent` or :class:`FileDeletedEvent`
+ """
+
+ def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
+ """Called when a file or directory is modified.
+
+ :param event:
+ Event representing file/directory modification.
+ :type event:
+ :class:`DirModifiedEvent` or :class:`FileModifiedEvent`
+ """
+
+ def on_closed(self, event: FileClosedEvent) -> None:
+ """Called when a file opened for writing is closed.
+
+ :param event:
+ Event representing file closing.
+ :type event:
+ :class:`FileClosedEvent`
+ """
+
+ def on_closed_no_write(self, event: FileClosedNoWriteEvent) -> None:
+ """Called when a file opened for reading is closed.
+
+ :param event:
+ Event representing file closing.
+ :type event:
+ :class:`FileClosedNoWriteEvent`
+ """
+
+ def on_opened(self, event: FileOpenedEvent) -> None:
+ """Called when a file is opened.
+
+ :param event:
+ Event representing file opening.
+ :type event:
+ :class:`FileOpenedEvent`
+ """
+
+
+class PatternMatchingEventHandler(FileSystemEventHandler):
+ """Matches given patterns with file paths associated with occurring events.
+ Uses pathlib's `PurePath.match()` method. `patterns` and `ignore_patterns`
+ are expected to be a list of strings.
+ """
+
+ def __init__(
+ self,
+ *,
+ patterns: list[str] | None = None,
+ ignore_patterns: list[str] | None = None,
+ ignore_directories: bool = False,
+ case_sensitive: bool = False,
+ ):
+ super().__init__()
+
+ self._patterns = patterns
+ self._ignore_patterns = ignore_patterns
+ self._ignore_directories = ignore_directories
+ self._case_sensitive = case_sensitive
+
+ @property
+ def patterns(self) -> list[str] | None:
+ """(Read-only)
+ Patterns to allow matching event paths.
+ """
+ return self._patterns
+
+ @property
+ def ignore_patterns(self) -> list[str] | None:
+ """(Read-only)
+ Patterns to ignore matching event paths.
+ """
+ return self._ignore_patterns
+
+ @property
+ def ignore_directories(self) -> bool:
+ """(Read-only)
+ ``True`` if directories should be ignored; ``False`` otherwise.
+ """
+ return self._ignore_directories
+
+ @property
+ def case_sensitive(self) -> bool:
+ """(Read-only)
+ ``True`` if path names should be matched sensitive to case; ``False``
+ otherwise.
+ """
+ return self._case_sensitive
+
+ def dispatch(self, event: FileSystemEvent) -> None:
+ """Dispatches events to the appropriate methods.
+
+ :param event:
+ The event object representing the file system event.
+ :type event:
+ :class:`FileSystemEvent`
+ """
+ if self.ignore_directories and event.is_directory:
+ return
+
+ paths = []
+ if hasattr(event, "dest_path"):
+ paths.append(os.fsdecode(event.dest_path))
+ if event.src_path:
+ paths.append(os.fsdecode(event.src_path))
+
+ if match_any_paths(
+ paths,
+ included_patterns=self.patterns,
+ excluded_patterns=self.ignore_patterns,
+ case_sensitive=self.case_sensitive,
+ ):
+ super().dispatch(event)
+
+
+class RegexMatchingEventHandler(FileSystemEventHandler):
+ """Matches given regexes with file paths associated with occurring events.
+ Uses the `re` module.
+ """
+
+ def __init__(
+ self,
+ *,
+ regexes: list[str] | None = None,
+ ignore_regexes: list[str] | None = None,
+ ignore_directories: bool = False,
+ case_sensitive: bool = False,
+ ):
+ super().__init__()
+
+ if regexes is None:
+ regexes = [r".*"]
+ elif isinstance(regexes, str):
+ regexes = [regexes]
+ if ignore_regexes is None:
+ ignore_regexes = []
+ if case_sensitive:
+ self._regexes = [re.compile(r) for r in regexes]
+ self._ignore_regexes = [re.compile(r) for r in ignore_regexes]
+ else:
+ self._regexes = [re.compile(r, re.IGNORECASE) for r in regexes]
+ self._ignore_regexes = [re.compile(r, re.IGNORECASE) for r in ignore_regexes]
+ self._ignore_directories = ignore_directories
+ self._case_sensitive = case_sensitive
+
+ @property
+ def regexes(self) -> list[re.Pattern[str]]:
+ """(Read-only)
+ Regexes to allow matching event paths.
+ """
+ return self._regexes
+
+ @property
+ def ignore_regexes(self) -> list[re.Pattern[str]]:
+ """(Read-only)
+ Regexes to ignore matching event paths.
+ """
+ return self._ignore_regexes
+
+ @property
+ def ignore_directories(self) -> bool:
+ """(Read-only)
+ ``True`` if directories should be ignored; ``False`` otherwise.
+ """
+ return self._ignore_directories
+
+ @property
+ def case_sensitive(self) -> bool:
+ """(Read-only)
+ ``True`` if path names should be matched sensitive to case; ``False``
+ otherwise.
+ """
+ return self._case_sensitive
+
+ def dispatch(self, event: FileSystemEvent) -> None:
+ """Dispatches events to the appropriate methods.
+
+ :param event:
+ The event object representing the file system event.
+ :type event:
+ :class:`FileSystemEvent`
+ """
+ if self.ignore_directories and event.is_directory:
+ return
+
+ paths = []
+ if hasattr(event, "dest_path"):
+ paths.append(os.fsdecode(event.dest_path))
+ if event.src_path:
+ paths.append(os.fsdecode(event.src_path))
+
+ if any(r.match(p) for r in self.ignore_regexes for p in paths):
+ return
+
+ if any(r.match(p) for r in self.regexes for p in paths):
+ super().dispatch(event)
+
+
+class LoggingEventHandler(FileSystemEventHandler):
+ """Logs all the events captured."""
+
+ def __init__(self, *, logger: logging.Logger | None = None) -> None:
+ super().__init__()
+ self.logger = logger or logging.root
+
+ def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
+ super().on_moved(event)
+
+ what = "directory" if event.is_directory else "file"
+ self.logger.info("Moved %s: from %s to %s", what, event.src_path, event.dest_path)
+
+ def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
+ super().on_created(event)
+
+ what = "directory" if event.is_directory else "file"
+ self.logger.info("Created %s: %s", what, event.src_path)
+
+ def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
+ super().on_deleted(event)
+
+ what = "directory" if event.is_directory else "file"
+ self.logger.info("Deleted %s: %s", what, event.src_path)
+
+ def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
+ super().on_modified(event)
+
+ what = "directory" if event.is_directory else "file"
+ self.logger.info("Modified %s: %s", what, event.src_path)
+
+ def on_closed(self, event: FileClosedEvent) -> None:
+ super().on_closed(event)
+
+ self.logger.info("Closed modified file: %s", event.src_path)
+
+ def on_closed_no_write(self, event: FileClosedNoWriteEvent) -> None:
+ super().on_closed_no_write(event)
+
+ self.logger.info("Closed read file: %s", event.src_path)
+
+ def on_opened(self, event: FileOpenedEvent) -> None:
+ super().on_opened(event)
+
+ self.logger.info("Opened file: %s", event.src_path)
+
+
+def generate_sub_moved_events(
+ src_dir_path: bytes | str,
+ dest_dir_path: bytes | str,
+) -> Generator[DirMovedEvent | FileMovedEvent]:
+ """Generates an event list of :class:`DirMovedEvent` and
+ :class:`FileMovedEvent` objects for all the files and directories within
+ the given moved directory that were moved along with the directory.
+
+ :param src_dir_path:
+ The source path of the moved directory.
+ :param dest_dir_path:
+ The destination path of the moved directory.
+ :returns:
+ An iterable of file system events of type :class:`DirMovedEvent` and
+ :class:`FileMovedEvent`.
+ """
+ for root, directories, filenames in os.walk(dest_dir_path): # type: ignore[type-var]
+ for directory in directories:
+ full_path = os.path.join(root, directory) # type: ignore[call-overload]
+ renamed_path = full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else ""
+ yield DirMovedEvent(renamed_path, full_path, is_synthetic=True)
+ for filename in filenames:
+ full_path = os.path.join(root, filename) # type: ignore[call-overload]
+ renamed_path = full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else ""
+ yield FileMovedEvent(renamed_path, full_path, is_synthetic=True)
+
+
+def generate_sub_created_events(src_dir_path: bytes | str) -> Generator[DirCreatedEvent | FileCreatedEvent]:
+ """Generates an event list of :class:`DirCreatedEvent` and
+ :class:`FileCreatedEvent` objects for all the files and directories within
+ the given moved directory that were moved along with the directory.
+
+ :param src_dir_path:
+ The source path of the created directory.
+ :returns:
+ An iterable of file system events of type :class:`DirCreatedEvent` and
+ :class:`FileCreatedEvent`.
+ """
+ for root, directories, filenames in os.walk(src_dir_path): # type: ignore[type-var]
+ for directory in directories:
+ full_path = os.path.join(root, directory) # type: ignore[call-overload]
+ yield DirCreatedEvent(full_path, is_synthetic=True)
+ for filename in filenames:
+ full_path = os.path.join(root, filename) # type: ignore[call-overload]
+ yield FileCreatedEvent(full_path, is_synthetic=True)
diff --git a/src/mcpstore/utils/watchdog/observers/__init__.py b/src/mcpstore/utils/watchdog/observers/__init__.py
new file mode 100644
index 00000000..2deeb74e
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/__init__.py
@@ -0,0 +1,91 @@
+""":module: watchdog.observers
+:synopsis: Observer that picks a native implementation if available.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+Classes
+=======
+.. autoclass:: Observer
+ :members:
+ :show-inheritance:
+ :inherited-members:
+
+Observer thread that schedules watching directories and dispatches
+calls to event handlers.
+
+You can also import platform specific classes directly and use it instead
+of :class:`Observer`. Here is a list of implemented observer classes.:
+
+============== ================================ ==============================
+Class Platforms Note
+============== ================================ ==============================
+|Inotify| Linux 2.6.13+ ``inotify(7)`` based observer
+|FSEvents| macOS FSEvents based observer
+|Kqueue| macOS and BSD with kqueue(2) ``kqueue(2)`` based observer
+|WinApi| Microsoft Windows Windows API-based observer
+|Polling| Any fallback implementation
+============== ================================ ==============================
+
+.. |Inotify| replace:: :class:`.inotify.InotifyObserver`
+.. |FSEvents| replace:: :class:`.fsevents.FSEventsObserver`
+.. |Kqueue| replace:: :class:`.kqueue.KqueueObserver`
+.. |WinApi| replace:: :class:`.read_directory_changes.WindowsApiObserver`
+.. |Polling| replace:: :class:`.polling.PollingObserver`
+
+"""
+
+from __future__ import annotations
+
+import contextlib
+import warnings
+from typing import TYPE_CHECKING, Protocol
+
+from mcpstore.utils.watchdog.utils import UnsupportedLibcError, platform
+
+if TYPE_CHECKING:
+ from mcpstore.utils.watchdog.observers.api import BaseObserver
+
+
+class ObserverType(Protocol):
+ def __call__(self, *, timeout: float = ...) -> BaseObserver: ...
+
+
+def _get_observer_cls() -> ObserverType:
+ if platform.is_linux():
+ with contextlib.suppress(UnsupportedLibcError):
+ from mcpstore.utils.watchdog.observers.inotify import InotifyObserver
+
+ return InotifyObserver
+ elif platform.is_darwin():
+ try:
+ from mcpstore.utils.watchdog.observers.fsevents import FSEventsObserver
+ except Exception:
+ try:
+ from mcpstore.utils.watchdog.observers.kqueue import KqueueObserver
+ except Exception:
+ warnings.warn("Failed to import fsevents and kqueue. Fall back to polling.", stacklevel=1)
+ else:
+ warnings.warn("Failed to import fsevents. Fall back to kqueue", stacklevel=1)
+ return KqueueObserver
+ else:
+ return FSEventsObserver
+ elif platform.is_windows():
+ try:
+ from mcpstore.utils.watchdog.observers.read_directory_changes import WindowsApiObserver
+ except Exception:
+ warnings.warn("Failed to import `read_directory_changes`. Fall back to polling.", stacklevel=1)
+ else:
+ return WindowsApiObserver
+ elif platform.is_bsd():
+ from mcpstore.utils.watchdog.observers.kqueue import KqueueObserver
+
+ return KqueueObserver
+
+ from mcpstore.utils.watchdog.observers.polling import PollingObserver
+
+ return PollingObserver
+
+
+Observer = _get_observer_cls()
+
+__all__ = ["Observer"]
diff --git a/src/mcpstore/utils/watchdog/observers/api.py b/src/mcpstore/utils/watchdog/observers/api.py
new file mode 100644
index 00000000..76bf78a0
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/api.py
@@ -0,0 +1,406 @@
+from __future__ import annotations
+
+import contextlib
+import queue
+import threading
+from collections import defaultdict
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.utils import BaseThread
+from mcpstore.utils.watchdog.utils.bricks import SkipRepeatsQueue
+
+if TYPE_CHECKING:
+ from mcpstore.utils.watchdog.events import FileSystemEvent, FileSystemEventHandler
+
+DEFAULT_EMITTER_TIMEOUT = 1.0 # in seconds
+DEFAULT_OBSERVER_TIMEOUT = 1.0 # in seconds
+
+
+class EventQueue(SkipRepeatsQueue):
+ """Thread-safe event queue based on a special queue that skips adding
+ the same event (:class:`FileSystemEvent`) multiple times consecutively.
+ Thus avoiding dispatching multiple event handling
+ calls when multiple identical events are produced quicker than an observer
+ can consume them.
+ """
+
+
+class ObservedWatch:
+ """An scheduled watch.
+
+ :param path:
+ Path string.
+ :param recursive:
+ ``True`` if watch is recursive; ``False`` otherwise.
+ :param event_filter:
+ Optional collection of :class:`watchdog.events.FileSystemEvent` to watch
+ """
+
+ def __init__(
+ self,
+ path: str | Path,
+ *,
+ recursive: bool,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ follow_symlink: bool = False,
+ ):
+ self._path = str(path) if isinstance(path, Path) else path
+ self._is_recursive = recursive
+ self._follow_symlink = follow_symlink
+ self._event_filter = frozenset(event_filter) if event_filter is not None else None
+
+ @property
+ def path(self) -> str:
+ """The path that this watch monitors."""
+ return self._path
+
+ @property
+ def is_recursive(self) -> bool:
+ """Determines whether subdirectories are watched for the path."""
+ return self._is_recursive
+
+ @property
+ def follow_symlink(self) -> bool:
+ """Determines whether symlink are followed."""
+ return self._follow_symlink
+
+ @property
+ def event_filter(self) -> frozenset[type[FileSystemEvent]] | None:
+ """Collection of event types watched for the path"""
+ return self._event_filter
+
+ @property
+ def key(self) -> tuple[str, bool, frozenset[type[FileSystemEvent]] | None]:
+ return self.path, self.is_recursive, self.event_filter
+
+ def __eq__(self, watch: object) -> bool:
+ if not isinstance(watch, ObservedWatch):
+ return NotImplemented
+ return self.key == watch.key
+
+ def __ne__(self, watch: object) -> bool:
+ if not isinstance(watch, ObservedWatch):
+ return NotImplemented
+ return self.key != watch.key
+
+ def __hash__(self) -> int:
+ return hash(self.key)
+
+ def __repr__(self) -> str:
+ if self.event_filter is not None:
+ event_filter_str = "|".join(sorted(_cls.__name__ for _cls in self.event_filter))
+ event_filter_str = f", event_filter={event_filter_str}"
+ else:
+ event_filter_str = ""
+ return f"<{type(self).__name__}: path={self.path!r}, is_recursive={self.is_recursive}{event_filter_str}>"
+
+
+# Observer classes
+class EventEmitter(BaseThread):
+ """Producer thread base class subclassed by event emitters
+ that generate events and populate a queue with them.
+
+ :param event_queue:
+ The event queue to populate with generated events.
+ :type event_queue:
+ :class:`watchdog.events.EventQueue`
+ :param watch:
+ The watch to observe and produce events for.
+ :type watch:
+ :class:`ObservedWatch`
+ :param timeout:
+ Timeout (in seconds) between successive attempts at reading events.
+ :type timeout:
+ ``float``
+ :param event_filter:
+ Collection of event types to emit, or None for no filtering (default).
+ :type event_filter:
+ Iterable[:class:`watchdog.events.FileSystemEvent`] | None
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ ) -> None:
+ super().__init__()
+ self._event_queue = event_queue
+ self._watch = watch
+ self._timeout = timeout
+ self._event_filter = frozenset(event_filter) if event_filter is not None else None
+
+ @property
+ def timeout(self) -> float:
+ """Blocking timeout for reading events."""
+ return self._timeout
+
+ @property
+ def watch(self) -> ObservedWatch:
+ """The watch associated with this emitter."""
+ return self._watch
+
+ def queue_event(self, event: FileSystemEvent) -> None:
+ """Queues a single event.
+
+ :param event:
+ Event to be queued.
+ :type event:
+ An instance of :class:`watchdog.events.FileSystemEvent`
+ or a subclass.
+ """
+ if self._event_filter is None or any(isinstance(event, cls) for cls in self._event_filter):
+ self._event_queue.put((event, self.watch))
+
+ def queue_events(self, timeout: float) -> None:
+ """Override this method to populate the event queue with events
+ per interval period.
+
+ :param timeout:
+ Timeout (in seconds) between successive attempts at
+ reading events.
+ :type timeout:
+ ``float``
+ """
+
+ def run(self) -> None:
+ while self.should_keep_running():
+ self.queue_events(self.timeout)
+
+
+class EventDispatcher(BaseThread):
+ """Consumer thread base class subclassed by event observer threads
+ that dispatch events from an event queue to appropriate event handlers.
+
+ :param timeout:
+ Timeout value (in seconds) passed to emitters
+ constructions in the child class BaseObserver.
+ :type timeout:
+ ``float``
+ """
+
+ stop_event = object()
+ """Event inserted into the queue to signal a requested stop."""
+
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__()
+ self._event_queue = EventQueue()
+ self._timeout = timeout
+
+ @property
+ def timeout(self) -> float:
+ """Timeout value to construct emitters with."""
+ return self._timeout
+
+ def stop(self) -> None:
+ BaseThread.stop(self)
+ with contextlib.suppress(queue.Full):
+ self.event_queue.put_nowait(EventDispatcher.stop_event)
+
+ @property
+ def event_queue(self) -> EventQueue:
+ """The event queue which is populated with file system events
+ by emitters and from which events are dispatched by a dispatcher
+ thread.
+ """
+ return self._event_queue
+
+ def dispatch_events(self, event_queue: EventQueue) -> None:
+ """Override this method to consume events from an event queue, blocking
+ on the queue for the specified timeout before raising :class:`queue.Empty`.
+
+ :param event_queue:
+ Event queue to populate with one set of events.
+ :type event_queue:
+ :class:`EventQueue`
+ :raises:
+ :class:`queue.Empty`
+ """
+
+ def run(self) -> None:
+ while self.should_keep_running():
+ try:
+ self.dispatch_events(self.event_queue)
+ except queue.Empty:
+ continue
+
+
+class BaseObserver(EventDispatcher):
+ """Base observer."""
+
+ def __init__(self, emitter_class: type[EventEmitter], *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__(timeout=timeout)
+ self._emitter_class = emitter_class
+ self._lock = threading.RLock()
+ self._watches: set[ObservedWatch] = set()
+ self._handlers: defaultdict[ObservedWatch, set[FileSystemEventHandler]] = defaultdict(set)
+ self._emitters: set[EventEmitter] = set()
+ self._emitter_for_watch: dict[ObservedWatch, EventEmitter] = {}
+
+ def _add_emitter(self, emitter: EventEmitter) -> None:
+ self._emitter_for_watch[emitter.watch] = emitter
+ self._emitters.add(emitter)
+
+ def _remove_emitter(self, emitter: EventEmitter) -> None:
+ del self._emitter_for_watch[emitter.watch]
+ self._emitters.remove(emitter)
+ emitter.stop()
+ with contextlib.suppress(RuntimeError):
+ emitter.join()
+
+ def _clear_emitters(self) -> None:
+ for emitter in self._emitters:
+ emitter.stop()
+ for emitter in self._emitters:
+ with contextlib.suppress(RuntimeError):
+ emitter.join()
+ self._emitters.clear()
+ self._emitter_for_watch.clear()
+
+ def _add_handler_for_watch(self, event_handler: FileSystemEventHandler, watch: ObservedWatch) -> None:
+ self._handlers[watch].add(event_handler)
+
+ def _remove_handlers_for_watch(self, watch: ObservedWatch) -> None:
+ del self._handlers[watch]
+
+ @property
+ def emitters(self) -> set[EventEmitter]:
+ """Returns event emitter created by this observer."""
+ return self._emitters
+
+ def start(self) -> None:
+ for emitter in self._emitters.copy():
+ try:
+ emitter.start()
+ except Exception:
+ self._remove_emitter(emitter)
+ raise
+ super().start()
+
+ def schedule(
+ self,
+ event_handler: FileSystemEventHandler,
+ path: str | Path,
+ *,
+ recursive: bool = False,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ follow_symlink: bool = False,
+ ) -> ObservedWatch:
+ """Schedules watching a path and calls appropriate methods specified
+ in the given event handler in response to file system events.
+
+ :param event_handler:
+ An event handler instance that has appropriate event handling
+ methods which will be called by the observer in response to
+ file system events.
+ :type event_handler:
+ :class:`watchdog.events.FileSystemEventHandler` or a subclass
+ :param path:
+ Directory path that will be monitored.
+ :type path:
+ ``str`` or :class:`pathlib.Path`
+ :param recursive:
+ ``True`` if events will be emitted for sub-directories
+ traversed recursively; ``False`` otherwise.
+ :type recursive:
+ ``bool``
+ :param event_filter:
+ Collection of event types to emit, or None for no filtering (default).
+ :type event_filter:
+ Iterable[:class:`watchdog.events.FileSystemEvent`] | None
+ :return:
+ An :class:`ObservedWatch` object instance representing
+ a watch.
+ """
+ with self._lock:
+ watch = ObservedWatch(path, recursive=recursive, event_filter=event_filter, follow_symlink=follow_symlink)
+ self._add_handler_for_watch(event_handler, watch)
+
+ # If we don't have an emitter for this watch already, create it.
+ if watch not in self._emitter_for_watch:
+ emitter = self._emitter_class(self.event_queue, watch, timeout=self.timeout, event_filter=event_filter)
+ if self.is_alive():
+ emitter.start()
+ self._add_emitter(emitter)
+ self._watches.add(watch)
+ return watch
+
+ def add_handler_for_watch(self, event_handler: FileSystemEventHandler, watch: ObservedWatch) -> None:
+ """Adds a handler for the given watch.
+
+ :param event_handler:
+ An event handler instance that has appropriate event handling
+ methods which will be called by the observer in response to
+ file system events.
+ :type event_handler:
+ :class:`watchdog.events.FileSystemEventHandler` or a subclass
+ :param watch:
+ The watch to add a handler for.
+ :type watch:
+ An instance of :class:`ObservedWatch` or a subclass of
+ :class:`ObservedWatch`
+ """
+ with self._lock:
+ self._add_handler_for_watch(event_handler, watch)
+
+ def remove_handler_for_watch(self, event_handler: FileSystemEventHandler, watch: ObservedWatch) -> None:
+ """Removes a handler for the given watch.
+
+ :param event_handler:
+ An event handler instance that has appropriate event handling
+ methods which will be called by the observer in response to
+ file system events.
+ :type event_handler:
+ :class:`watchdog.events.FileSystemEventHandler` or a subclass
+ :param watch:
+ The watch to remove a handler for.
+ :type watch:
+ An instance of :class:`ObservedWatch` or a subclass of
+ :class:`ObservedWatch`
+ """
+ with self._lock:
+ self._handlers[watch].remove(event_handler)
+
+ def unschedule(self, watch: ObservedWatch) -> None:
+ """Unschedules a watch.
+
+ :param watch:
+ The watch to unschedule.
+ :type watch:
+ An instance of :class:`ObservedWatch` or a subclass of
+ :class:`ObservedWatch`
+ """
+ with self._lock:
+ emitter = self._emitter_for_watch[watch]
+ del self._handlers[watch]
+ self._remove_emitter(emitter)
+ self._watches.remove(watch)
+
+ def unschedule_all(self) -> None:
+ """Unschedules all watches and detaches all associated event handlers."""
+ with self._lock:
+ self._handlers.clear()
+ self._clear_emitters()
+ self._watches.clear()
+
+ def on_thread_stop(self) -> None:
+ self.unschedule_all()
+
+ def dispatch_events(self, event_queue: EventQueue) -> None:
+ entry = event_queue.get(block=True)
+ if entry is EventDispatcher.stop_event:
+ return
+
+ event, watch = entry
+
+ with self._lock:
+ # To allow unschedule/stop and safe removal of event handlers
+ # within event handlers itself, check if the handler is still
+ # registered after every dispatch.
+ for handler in self._handlers[watch].copy():
+ if handler in self._handlers[watch]:
+ handler.dispatch(event)
+ event_queue.task_done()
diff --git a/src/mcpstore/utils/watchdog/observers/fsevents.py b/src/mcpstore/utils/watchdog/observers/fsevents.py
new file mode 100644
index 00000000..baa0c241
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/fsevents.py
@@ -0,0 +1,345 @@
+""":module: watchdog.observers.fsevents
+:synopsis: FSEvents based emitter implementation.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+:platforms: macOS
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import threading
+import time
+import unicodedata
+from typing import TYPE_CHECKING
+
+import _watchdog_fsevents as _fsevents
+
+from mcpstore.utils.watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ generate_sub_created_events,
+ generate_sub_moved_events,
+)
+from mcpstore.utils.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, \
+ EventEmitter
+from mcpstore.utils.watchdog.utils.dirsnapshot import DirectorySnapshot
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from mcpstore.utils.watchdog.events import FileSystemEvent, FileSystemEventHandler
+ from mcpstore.utils.watchdog.observers.api import EventQueue, ObservedWatch
+
+
+logger = logging.getLogger("fsevents")
+
+
+class FSEventsEmitter(EventEmitter):
+ """macOS FSEvents Emitter class.
+
+ :param event_queue:
+ The event queue to fill with events.
+ :param watch:
+ A watch object representing the directory to monitor.
+ :type watch:
+ :class:`watchdog.observers.api.ObservedWatch`
+ :param timeout:
+ Read events blocking timeout (in seconds).
+ :param event_filter:
+ Collection of event types to emit, or None for no filtering (default).
+ :param suppress_history:
+ The FSEvents API may emit historic events up to 30 sec before the watch was
+ started. When ``suppress_history`` is ``True``, those events will be suppressed
+ by creating a directory snapshot of the watched path before starting the stream
+ as a reference to suppress old events. Warning: This may result in significant
+ memory usage in case of a large number of items in the watched path.
+ :type timeout:
+ ``float``
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ suppress_history: bool = False,
+ ) -> None:
+ super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
+ self._fs_view: set[int] = set()
+ self.suppress_history = suppress_history
+ self._start_time = 0.0
+ self._starting_state: DirectorySnapshot | None = None
+ self._lock = threading.Lock()
+ self._absolute_watch_path = os.path.realpath(os.path.abspath(os.path.expanduser(self.watch.path)))
+
+ def on_thread_stop(self) -> None:
+ _fsevents.remove_watch(self.watch)
+ _fsevents.stop(self)
+
+ def queue_event(self, event: FileSystemEvent) -> None:
+ # fsevents defaults to be recursive, so if the watch was meant to be non-recursive then we need to drop
+ # all the events here which do not have a src_path / dest_path that matches the watched path
+ if self._watch.is_recursive or not self._is_recursive_event(event):
+ logger.debug("queue_event %s", event)
+ EventEmitter.queue_event(self, event)
+ else:
+ logger.debug("drop event %s", event)
+
+ def _is_recursive_event(self, event: FileSystemEvent) -> bool:
+ src_path = event.src_path if event.is_directory else os.path.dirname(event.src_path)
+ if src_path == self._absolute_watch_path:
+ return False
+
+ if isinstance(event, (FileMovedEvent, DirMovedEvent)):
+ # when moving something into the watch path we must always take the dirname,
+ # otherwise we miss out on `DirMovedEvent`s
+ dest_path = os.path.dirname(event.dest_path)
+ if dest_path == self._absolute_watch_path:
+ return False
+
+ return True
+
+ def _queue_created_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
+ cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
+ self.queue_event(cls(src_path))
+ self.queue_event(DirModifiedEvent(dirname))
+
+ def _queue_deleted_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
+ cls = DirDeletedEvent if event.is_directory else FileDeletedEvent
+ self.queue_event(cls(src_path))
+ self.queue_event(DirModifiedEvent(dirname))
+
+ def _queue_modified_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
+ cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
+ self.queue_event(cls(src_path))
+
+ def _queue_renamed_event(
+ self,
+ src_event: FileSystemEvent,
+ src_path: bytes | str,
+ dst_path: bytes | str,
+ src_dirname: bytes | str,
+ dst_dirname: bytes | str,
+ ) -> None:
+ cls = DirMovedEvent if src_event.is_directory else FileMovedEvent
+ dst_path = self._encode_path(dst_path)
+ self.queue_event(cls(src_path, dst_path))
+ self.queue_event(DirModifiedEvent(src_dirname))
+ self.queue_event(DirModifiedEvent(dst_dirname))
+
+ def _is_historic_created_event(self, event: _fsevents.NativeEvent) -> bool:
+ # We only queue a created event if the item was created after we
+ # started the FSEventsStream.
+
+ in_history = event.inode in self._fs_view
+
+ if self._starting_state:
+ try:
+ old_inode = self._starting_state.inode(event.path)[0]
+ before_start = old_inode == event.inode
+ except KeyError:
+ before_start = False
+ else:
+ before_start = False
+
+ return in_history or before_start
+
+ @staticmethod
+ def _is_meta_mod(event: _fsevents.NativeEvent) -> bool:
+ """Returns True if the event indicates a change in metadata."""
+ return event.is_inode_meta_mod or event.is_xattr_mod or event.is_owner_change
+
+ def queue_events(self, timeout: float, events: list[_fsevents.NativeEvent]) -> None: # type: ignore[override]
+ if logger.getEffectiveLevel() <= logging.DEBUG:
+ for event in events:
+ flags = ", ".join(attr for attr in dir(event) if getattr(event, attr) is True)
+ logger.debug("%s: %s", event, flags)
+
+ if time.monotonic() - self._start_time > 60:
+ # Event history is no longer needed, let's free some memory.
+ self._starting_state = None
+
+ while events:
+ event = events.pop(0)
+
+ src_path = self._encode_path(event.path)
+ src_dirname = os.path.dirname(src_path)
+
+ try:
+ stat = os.stat(src_path)
+ except OSError:
+ stat = None
+
+ exists = stat and stat.st_ino == event.inode
+
+ # FSevents may coalesce multiple events for the same item + path into a
+ # single event. However, events are never coalesced for different items at
+ # the same path or for the same item at different paths. Therefore, the
+ # event chains "removed -> created" and "created -> renamed -> removed" will
+ # never emit a single native event and a deleted event *always* means that
+ # the item no longer existed at the end of the event chain.
+
+ # Some events will have a spurious `is_created` flag set, coalesced from an
+ # already emitted and processed CreatedEvent. To filter those, we keep track
+ # of all inodes which we know to be already created. This is safer than
+ # keeping track of paths since paths are more likely to be reused than
+ # inodes.
+
+ # Likewise, some events will have a spurious `is_modified`,
+ # `is_inode_meta_mod` or `is_xattr_mod` flag set. We currently do not
+ # suppress those but could do so if the item still exists by caching the
+ # stat result and verifying that it did change.
+
+ if event.is_created and event.is_removed:
+ # Events will only be coalesced for the same item / inode.
+ # The sequence deleted -> created therefore cannot occur.
+ # Any combination with renamed cannot occur either.
+
+ if not self._is_historic_created_event(event):
+ self._queue_created_event(event, src_path, src_dirname)
+
+ self._fs_view.add(event.inode)
+
+ if event.is_modified or self._is_meta_mod(event):
+ self._queue_modified_event(event, src_path, src_dirname)
+
+ self._queue_deleted_event(event, src_path, src_dirname)
+ self._fs_view.discard(event.inode)
+
+ else:
+ if event.is_created and not self._is_historic_created_event(event):
+ self._queue_created_event(event, src_path, src_dirname)
+
+ self._fs_view.add(event.inode)
+
+ if event.is_modified or self._is_meta_mod(event):
+ self._queue_modified_event(event, src_path, src_dirname)
+
+ if event.is_renamed:
+ # Check if we have a corresponding destination event in the watched path.
+ dst_event = next(
+ iter(e for e in events if e.is_renamed and e.inode == event.inode),
+ None,
+ )
+
+ if dst_event:
+ # Item was moved within the watched folder.
+ logger.debug("Destination event for rename is %s", dst_event)
+
+ dst_path = self._encode_path(dst_event.path)
+ dst_dirname = os.path.dirname(dst_path)
+
+ self._queue_renamed_event(event, src_path, dst_path, src_dirname, dst_dirname)
+ self._fs_view.add(event.inode)
+
+ for sub_moved_event in generate_sub_moved_events(src_path, dst_path):
+ self.queue_event(sub_moved_event)
+
+ # Process any coalesced flags for the dst_event.
+
+ events.remove(dst_event)
+
+ if dst_event.is_modified or self._is_meta_mod(dst_event):
+ self._queue_modified_event(dst_event, dst_path, dst_dirname)
+
+ if dst_event.is_removed:
+ self._queue_deleted_event(dst_event, dst_path, dst_dirname)
+ self._fs_view.discard(dst_event.inode)
+
+ elif exists:
+ # This is the destination event, item was moved into the watched
+ # folder.
+ self._queue_created_event(event, src_path, src_dirname)
+ self._fs_view.add(event.inode)
+
+ for sub_created_event in generate_sub_created_events(src_path):
+ self.queue_event(sub_created_event)
+
+ else:
+ # This is the source event, item was moved out of the watched
+ # folder.
+ self._queue_deleted_event(event, src_path, src_dirname)
+ self._fs_view.discard(event.inode)
+
+ # Skip further coalesced processing.
+ continue
+
+ if event.is_removed:
+ # Won't occur together with renamed.
+ self._queue_deleted_event(event, src_path, src_dirname)
+ self._fs_view.discard(event.inode)
+
+ if event.is_root_changed:
+ # This will be set if root or any of its parents is renamed or deleted.
+ # TODO: find out new path and generate DirMovedEvent?
+ self.queue_event(DirDeletedEvent(self.watch.path))
+ logger.debug("Stopping because root path was changed")
+ self.stop()
+
+ self._fs_view.clear()
+
+ def events_callback(self, paths: list[bytes], inodes: list[int], flags: list[int], ids: list[int]) -> None:
+ """Callback passed to FSEventStreamCreate(), it will receive all
+ FS events and queue them.
+ """
+ cls = _fsevents.NativeEvent
+ try:
+ events = [
+ cls(path, inode, event_flags, event_id)
+ for path, inode, event_flags, event_id in zip(paths, inodes, flags, ids)
+ ]
+ with self._lock:
+ self.queue_events(self.timeout, events)
+ except Exception:
+ logger.exception("Unhandled exception in fsevents callback")
+
+ def run(self) -> None:
+ self.pathnames = [self.watch.path]
+ self._start_time = time.monotonic()
+ try:
+ _fsevents.add_watch(self, self.watch, self.events_callback, self.pathnames)
+ _fsevents.read_events(self)
+ except Exception:
+ logger.exception("Unhandled exception in FSEventsEmitter")
+
+ def on_thread_start(self) -> None:
+ if self.suppress_history:
+ watch_path = os.fsdecode(self.watch.path) if isinstance(self.watch.path, bytes) else self.watch.path
+ self._starting_state = DirectorySnapshot(watch_path)
+
+ def _encode_path(self, path: bytes | str) -> bytes | str:
+ """Encode path only if bytes were passed to this emitter."""
+ return os.fsencode(path) if isinstance(self.watch.path, bytes) else path
+
+
+class FSEventsObserver(BaseObserver):
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__(FSEventsEmitter, timeout=timeout)
+
+ def schedule(
+ self,
+ event_handler: FileSystemEventHandler,
+ path: str | Path,
+ *,
+ recursive: bool = False,
+ follow_symlink: bool = False,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ ) -> ObservedWatch:
+ # Fix for issue #26: Trace/BPT error when given a unicode path
+ # string. https://github.com/gorakhargosh/watchdog/issues#issue/26
+ if isinstance(path, str):
+ path = unicodedata.normalize("NFC", path)
+
+ return super().schedule(
+ event_handler, path, recursive=recursive, follow_symlink=follow_symlink, event_filter=event_filter
+ )
diff --git a/src/mcpstore/utils/watchdog/observers/inotify.py b/src/mcpstore/utils/watchdog/observers/inotify.py
new file mode 100644
index 00000000..d1379977
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/inotify.py
@@ -0,0 +1,557 @@
+""":module: watchdog.observers.inotify
+:synopsis: ``inotify(7)`` based emitter implementation.
+:author: Sebastien Martini
+:author: Luke McCarthy
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Tim Cuthbertson
+:author: Mickaël Schoentgen
+:author: Joachim Coenen
+:platforms: Linux 2.6.13+.
+
+.. ADMONITION:: About system requirements
+
+ Recommended minimum kernel version: 2.6.25.
+
+ Quote from the inotify(7) man page:
+
+ "Inotify was merged into the 2.6.13 Linux kernel. The required library
+ interfaces were added to glibc in version 2.4. (IN_DONT_FOLLOW,
+ IN_MASK_ADD, and IN_ONLYDIR were only added in version 2.5.)"
+
+ Therefore, you must ensure the system is running at least these versions
+ appropriate libraries and the kernel.
+
+.. ADMONITION:: About recursiveness, event order, and event coalescing
+
+ Quote from the inotify(7) man page:
+
+ If successive output inotify events produced on the inotify file
+ descriptor are identical (same wd, mask, cookie, and name) then they
+ are coalesced into a single event if the older event has not yet been
+ read (but see BUGS).
+
+ The events returned by reading from an inotify file descriptor form
+ an ordered queue. Thus, for example, it is guaranteed that when
+ renaming from one directory to another, events will be produced in
+ the correct order on the inotify file descriptor.
+
+ ...
+
+ Inotify monitoring of directories is not recursive: to monitor
+ subdirectories under a directory, additional watches must be created.
+
+ This emitter implementation therefore automatically adds watches for
+ sub-directories if running in recursive mode.
+
+.. ADMONITION:: Challenges with the inotify API:
+ inotify has some limitations:
+
+ - A watch on a file/folder is not informed when the file/folder itself or any containing (outer) folders is moved.
+ - When a file is moved from a watched directory to a different directory, there will only be an IN_MOVE_FROM event
+ for the watch on that directory.
+ - When a file is moved from an unwatched directory to a watched directory, there will only be an IN_MOVE_TO event
+ for the watch on that directory.
+
+ If we were to keep track of the path of watches in InotifyFD, an
+ InotifyWatchGroup would get different events depending on whether there are
+ other InotifyWatchGroups for exactly the right set of paths or not. The same
+ goes for coalescing move events.
+
+ Therefore, both things are handled by the InotifyWatchGroups themselves.
+
+Some extremely useful articles and documentation:
+
+.. _inotify FAQ: http://inotify.aiken.cz/?section=inotify&page=faq&lang=en
+.. _intro to inotify: http://www.linuxjournal.com/article/8478
+
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+import threading
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Protocol, cast
+
+from mcpstore.utils.watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileClosedEvent,
+ FileClosedNoWriteEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ FileOpenedEvent,
+ FileSystemEvent,
+ generate_sub_created_events,
+ generate_sub_moved_events,
+)
+from mcpstore.utils.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, \
+ EventEmitter
+from mcpstore.utils.watchdog.observers.inotify_c import (
+ WATCHDOG_ALL_EVENTS,
+ CallbackId,
+ InotifyConstants,
+ InotifyEvent,
+ InotifyFD,
+ Mask,
+ WatchCallback,
+ WatchDescriptor,
+)
+from mcpstore.utils.watchdog.observers.inotify_move_event_grouper import (
+ GroupedInotifyEvent,
+ InotifyMoveEventGrouper,
+ PathedInotifyEvent,
+)
+
+if TYPE_CHECKING:
+ from mcpstore.utils.watchdog.observers.api import EventQueue, ObservedWatch
+
+logger = logging.getLogger(__name__)
+
+
+class FileSystemEventCtor(Protocol):
+ def __call__(self, src_path: bytes | str, dest_path: bytes | str = "") -> FileSystemEvent: ...
+
+
+@dataclass
+class InotifyWatchGroup(WatchCallback):
+ """Linux inotify(7) API wrapper class.
+
+ Bundles everything needed to watch a file or (possibly recursive) directory.
+ Manages the watches needed and coalesces IN_MOVE_FROM and IN_MOVE_TO events.
+
+ In order to preserve consistency the behavior of one InotifyWatchGroup must
+ be independent of the existence of any other InotifyWatchGroup.
+ Therefore, an InotifyWatchGroup is itself responsible for:
+
+ - keeping track of the actual path a watch watches (including tracking moves, if possible)
+ - coalescing move events.
+
+ :param path:
+ The directory path for which we want an inotify object.
+ :type path:
+ :class:`bytes`
+ :param is_recursive:
+ ``True`` if subdirectories should be monitored; ``False`` otherwise.
+ """
+
+ _inotify_fd: InotifyFD
+ """The inotify instance to use"""
+ path: bytes
+ """Whether we are watching directories recursively."""
+ event_mask: Mask = field(default=Mask(0))
+ """The path associated with the inotify instance."""
+ is_recursive: bool = False
+ """The event mask for this inotify instance."""
+ follow_symlink: bool = False
+
+ _move_event_grouper: InotifyMoveEventGrouper = field(default_factory=InotifyMoveEventGrouper, init=False)
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False)
+ _id: CallbackId = field(init=False)
+
+ _is_active: bool = field(default=False, init=False)
+
+ _active_callbacks_by_watch: dict[WatchDescriptor, bytes] = field(default_factory=dict, init=False)
+ _active_callbacks_by_path: dict[bytes, WatchDescriptor] = field(default_factory=dict, init=False)
+
+ def __post_init__(self) -> None:
+ self.event_mask = InotifyWatchGroup.build_event_mask(self.event_mask, follow_symlink=self.follow_symlink)
+ self._id = CallbackId(id(self))
+ self._activate()
+
+ @staticmethod
+ def build_event_mask(event_mask: Mask, *, follow_symlink: bool) -> Mask:
+ if follow_symlink:
+ event_mask = Mask(event_mask & ~InotifyConstants.IN_DONT_FOLLOW)
+ else:
+ event_mask = Mask(event_mask | InotifyConstants.IN_DONT_FOLLOW)
+ return event_mask
+
+ @property
+ def is_active(self) -> bool:
+ """Returns True if there are any callbacks active"""
+ return self._is_active
+
+ def _source_for_move(self, cookie: int) -> bytes | None:
+ """The source path corresponding to the given MOVED_TO event.
+
+ If the source path is outside the monitored directories, None
+ is returned instead.
+ """
+ src_event = self._move_event_grouper.get_queued_moved_from_event(cookie)
+ if src_event is not None:
+ return src_event.path
+ return None
+
+ @property
+ def _callback(self) -> WatchCallback:
+ return self
+
+ def read_event(self) -> GroupedInotifyEvent | None:
+ """Returns a single event or a tuple of from/to events in case of a
+ paired move event. If this buffer has been closed, raise the Closed
+ exception.
+ """
+ return self._move_event_grouper.read_event()
+
+ def on_watch_deleted(self, wd: WatchDescriptor) -> None:
+ """Called when a watch that ths callback is registered at is removed.
+ This is the case when the watched object is deleted."""
+ with self._lock:
+ if not self.is_active:
+ return
+ self._remove_watch_internally(wd)
+
+ def on_event(self, event: InotifyEvent) -> None:
+ """called for every event for each watch this callback is registered at."""
+ with self._lock:
+ if not self.is_active:
+ return
+ src_path = self._build_event_source_path(event)
+ if src_path is None:
+ return
+
+ # todo look into desired behavior for IN_MOVE_SELF events
+ # (keep watching?, stop watching?, are they even possible?)
+ if event.is_moved_from:
+ # TODO: When a directory from a watched directory
+ # is moved into another part of the filesystem, this
+ # will not generate DELETE events for the directory tree.
+ # We need to coalesce IN_MOVED_FROM events and those
+ # IN_MOVED_FROM events which don't pair up with
+ # IN_MOVED_TO events should be marked IN_DELETE (maybe?)
+ # instead relative to this directory. And the respective
+ # callbacks for the directory and sub directory must be removed.
+ #
+ # also: hold back all other events for this directory and its
+ # subdirectories, until we know whether it is still watched
+ pass
+ elif event.is_moved_to:
+ move_src_path = self._source_for_move(event.cookie)
+ move_dst_path = src_path
+ if move_src_path is not None:
+ self._move_watches(move_src_path, move_dst_path)
+ # TODO: When a directory from another part of the
+ # filesystem is moved into a watched directory, this
+ # will not generate events for the directory tree.
+ # We need to coalesce IN_MOVED_TO events and those
+ # IN_MOVED_TO events which don't pair up with
+ # IN_MOVED_FROM events should be marked IN_CREATE
+ # instead relative to this directory.
+ elif event.is_create and event.is_directory and self.is_recursive:
+ self._add_all_callbacks(src_path)
+
+ self._move_event_grouper.put_event(PathedInotifyEvent(event, src_path))
+
+ if event.is_create and event.is_directory and self.is_recursive:
+ for sub_event in self._recursive_simulate(src_path):
+ sub_src_path = self._build_event_source_path(sub_event)
+ if sub_src_path is not None:
+ self._move_event_grouper.put_event(PathedInotifyEvent(sub_event, sub_src_path))
+
+ def _recursive_simulate(self, src_path: bytes) -> list[InotifyEvent]:
+ # HACK: We need to traverse the directory path recursively and simulate
+ # events for newly created subdirectories/files.
+ # This will handle: mkdir -p foobar/blah/bar; touch foobar/afile
+ events = []
+ for root, dirnames, filenames in os.walk(src_path, followlinks=self.follow_symlink):
+ for dirname in dirnames:
+ full_path = os.path.join(root, dirname)
+ wd_dir = self._active_callbacks_by_path[os.path.dirname(full_path)]
+ mask = Mask(InotifyConstants.IN_CREATE | InotifyConstants.IN_ISDIR)
+ events.append(InotifyEvent(wd_dir, mask, 0, dirname))
+
+ for filename in filenames:
+ full_path = os.path.join(root, filename)
+ wd_parent_dir = self._active_callbacks_by_path[os.path.dirname(full_path)]
+ mask = InotifyConstants.IN_CREATE
+ events.append(InotifyEvent(wd_parent_dir, mask, 0, filename))
+ return events
+
+ def deactivate(self) -> None:
+ """Removes all associated watches."""
+ with self._lock:
+ self._is_active = False
+ self._remove_callbacks(list(self._active_callbacks_by_watch))
+ self._move_event_grouper.close()
+
+ def _activate(self) -> None:
+ """Adds a watch (optionally recursively) for the given directory path
+ to monitor events specified by the mask.
+ """
+ with self._lock:
+ if self.is_active: # maybe wwe can remove this check...
+ return
+
+ self._add_all_callbacks(self.path)
+ self._is_active = True
+
+ # Non-synchronized methods:
+
+ def _build_event_source_path(self, event: InotifyEvent) -> bytes | None:
+ watched_path = self._active_callbacks_by_watch.get(event.wd)
+ if watched_path is None:
+ # investigate: can we *actually* get events for a WatchDescriptor that has already been removed?
+ return None
+ return os.path.join(watched_path, event.name) if event.name else watched_path # avoid trailing slash
+
+ def _move_watches(self, move_src_path: bytes, move_dst_path: bytes) -> None:
+ """moves all watches that are inside the directory move_src_path to move_dst_path"""
+ moved_watch = self._active_callbacks_by_path.pop(move_src_path, None)
+ if moved_watch is not None:
+ self._active_callbacks_by_watch[moved_watch] = move_dst_path
+ self._active_callbacks_by_path[move_dst_path] = moved_watch
+
+ # move all watches within this directory
+ move_src_prefix = move_src_path + os.path.sep.encode()
+ for path, wd in self._active_callbacks_by_path.copy().items():
+ if path.startswith(move_src_prefix):
+ del self._active_callbacks_by_path[path]
+ path = path.replace(move_src_path, move_dst_path, 1)
+ self._active_callbacks_by_watch[wd] = path
+ self._active_callbacks_by_path[path] = wd
+
+ def _add_all_callbacks(self, path: bytes) -> None:
+ """Adds a watch (optionally recursively) for the given directory path
+ to monitor events specified by the mask.
+
+ :param path:
+ Path to monitor
+ """
+ is_dir = os.path.isdir(path)
+ self._add_callback(path)
+ if is_dir and self.is_recursive:
+ for root, dirnames, _ in os.walk(path, followlinks=self.follow_symlink):
+ for dirname in dirnames:
+ full_path = os.path.join(root, dirname)
+ if not self.follow_symlink and os.path.islink(full_path):
+ continue
+ self._add_callback(full_path)
+
+ def _add_callback(self, path: bytes) -> None:
+ """Adds a callback for the given path to monitor events specified by the
+ mask.
+
+ :param path:
+ Path to monitor
+ """
+ with contextlib.suppress(OSError):
+ wd = self._inotify_fd.add_callback(path, self.event_mask, self._callback, self._id)
+ self._active_callbacks_by_path[path] = wd
+ self._active_callbacks_by_watch[wd] = path
+
+ def _remove_callbacks(self, wds: list[WatchDescriptor]) -> None:
+ """removes callbacks for the given paths.
+
+ :param wds:
+ a list of WatchDescriptors
+ """
+ self._inotify_fd.remove_callbacks([(wd, self._id) for wd in wds])
+ for wd in wds:
+ self._remove_watch_internally(wd)
+
+ def _remove_watch_internally(self, wd: WatchDescriptor) -> None:
+ """Removes a watch descriptor from internal dicts."""
+ path = self._active_callbacks_by_watch.pop(wd)
+ wd2 = self._active_callbacks_by_path.pop(path)
+ if wd2 != wd:
+ # Oops. The path already belongs to a different wd. Put it back.
+ # This can happen, when events are sent slightly out of order.
+ self._active_callbacks_by_path[path] = wd2
+
+
+def _select_event_type(
+ dir_event: type[FileSystemEvent],
+ file_event: type[FileSystemEvent],
+ *,
+ is_directory: bool,
+) -> FileSystemEventCtor:
+ """selects the correct FileSystemEvent Type based on `is_directory` and returns it."""
+ return cast(FileSystemEventCtor, dir_event if is_directory else file_event)
+
+
+class InotifyEmitter(EventEmitter):
+ """inotify(7)-based event emitter.
+
+ :param event_queue:
+ The event queue to fill with events.
+ :param watch:
+ A watch object representing the directory to monitor.
+ :type watch:
+ :class:`watchdog.observers.api.ObservedWatch`
+ :param timeout:
+ Read events blocking timeout (in seconds).
+ :type timeout:
+ ``float``
+ :param event_filter:
+ Collection of event types to emit, or None for no filtering (default).
+ :type event_filter:
+ Iterable[:class:`watchdog.events.FileSystemEvent`] | None
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ inotify_fd: InotifyFD | None = None,
+ ) -> None:
+ super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
+ self._lock: threading.Lock = threading.Lock()
+ self._inotify_fd: InotifyFD = inotify_fd if inotify_fd is not None else InotifyFD.get_instance()
+ self._inotify: InotifyWatchGroup | None = None
+
+ def on_thread_start(self) -> None:
+ path = os.fsencode(self.watch.path)
+ event_mask = self.get_event_mask_from_filter()
+ self._inotify = InotifyWatchGroup(
+ self._inotify_fd,
+ path,
+ is_recursive=self.watch.is_recursive,
+ event_mask=event_mask,
+ follow_symlink=self.watch.follow_symlink,
+ )
+
+ def on_thread_stop(self) -> None:
+ if self._inotify is not None:
+ self._inotify.deactivate()
+ self._inotify = None
+
+ def queue_events(self, timeout: float, *, full_events: bool = False) -> None:
+ # If "full_events" is true, then the method will report unmatched move events as separate events
+ # This behavior is by default only called by a InotifyFullEmitter
+ if self._inotify is None:
+ logger.error("InotifyEmitter.queue_events() called when the thread is inactive")
+ return
+ with self._lock:
+ if self._inotify is None:
+ logger.error("InotifyEmitter.queue_events() called when the thread is inactive")
+ return
+ event = self._inotify.read_event()
+ if event is None:
+ return
+ self.build_and_queue_event(event)
+
+ def build_and_queue_event(self, event: GroupedInotifyEvent, *, full_events: bool = False) -> None:
+ """called for every event for each watch this callback is registered at."""
+ cls: FileSystemEventCtor
+ if not isinstance(event, PathedInotifyEvent):
+ # we got a move event tuple
+ move_from, move_to = event
+ src_path = self._decode_path(move_from.path)
+ dest_path = self._decode_path(move_to.path)
+ cls = _select_event_type(DirMovedEvent, FileMovedEvent, is_directory=move_from.ev.is_directory)
+ self.queue_event(cls(src_path, dest_path))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ self.queue_event(DirModifiedEvent(os.path.dirname(dest_path)))
+ if move_from.ev.is_directory and self.watch.is_recursive:
+ for sub_moved_event in generate_sub_moved_events(src_path, dest_path):
+ self.queue_event(sub_moved_event)
+ else:
+ src_path = self._decode_path(event.path)
+ if event.ev.is_moved_to:
+ if full_events:
+ cls = _select_event_type(DirMovedEvent, FileMovedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls("", src_path))
+ else:
+ cls = _select_event_type(DirCreatedEvent, FileCreatedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ if event.ev.is_directory and self.watch.is_recursive:
+ for sub_created_event in generate_sub_created_events(src_path):
+ self.queue_event(sub_created_event)
+ elif event.ev.is_attrib or event.ev.is_modify:
+ cls = _select_event_type(DirModifiedEvent, FileModifiedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path))
+ elif event.ev.is_delete or (event.ev.is_moved_from and not full_events):
+ cls = _select_event_type(DirDeletedEvent, FileDeletedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ elif event.ev.is_moved_from and full_events:
+ cls = _select_event_type(DirMovedEvent, FileMovedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path, ""))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ elif event.ev.is_create:
+ cls = _select_event_type(DirCreatedEvent, FileCreatedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ elif event.ev.is_delete_self and src_path == self.watch.path:
+ cls = _select_event_type(DirDeletedEvent, FileDeletedEvent, is_directory=event.ev.is_directory)
+ self.queue_event(cls(src_path))
+ self.stop()
+ elif not event.ev.is_directory:
+ if event.ev.is_open:
+ self.queue_event(FileOpenedEvent(src_path))
+ elif event.ev.is_close_write:
+ self.queue_event(FileClosedEvent(src_path))
+ self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
+ elif event.ev.is_close_nowrite:
+ self.queue_event(FileClosedNoWriteEvent(src_path))
+
+ def _decode_path(self, path: bytes) -> bytes | str:
+ """Decode path only if unicode string was passed to this emitter."""
+ return path if isinstance(self.watch.path, bytes) else os.fsdecode(path)
+
+ def get_event_mask_from_filter(self) -> Mask:
+ """Optimization: Only include events we are filtering in inotify call."""
+ if self._event_filter is None:
+ return WATCHDOG_ALL_EVENTS
+
+ # Always listen to delete self
+ event_mask = InotifyConstants.IN_DELETE_SELF
+
+ for cls in self._event_filter:
+ if cls in {DirMovedEvent, FileMovedEvent}:
+ event_mask = Mask(event_mask | InotifyConstants.IN_MOVE)
+ elif cls in {DirCreatedEvent, FileCreatedEvent}:
+ event_mask = Mask(event_mask | InotifyConstants.IN_MOVE | InotifyConstants.IN_CREATE)
+ elif cls is DirModifiedEvent:
+ event_mask = Mask(
+ event_mask
+ | (
+ InotifyConstants.IN_MOVE
+ | InotifyConstants.IN_ATTRIB
+ | InotifyConstants.IN_MODIFY
+ | InotifyConstants.IN_CREATE
+ | InotifyConstants.IN_CLOSE_WRITE
+ )
+ )
+ elif cls is FileModifiedEvent:
+ event_mask = Mask(event_mask | InotifyConstants.IN_ATTRIB | InotifyConstants.IN_MODIFY)
+ elif cls in {DirDeletedEvent, FileDeletedEvent}:
+ event_mask = Mask(event_mask | InotifyConstants.IN_DELETE)
+ elif cls is FileClosedEvent:
+ event_mask = Mask(event_mask | InotifyConstants.IN_CLOSE_WRITE)
+ elif cls is FileClosedNoWriteEvent:
+ event_mask = Mask(event_mask | InotifyConstants.IN_CLOSE_NOWRITE)
+ elif cls is FileOpenedEvent:
+ event_mask = Mask(event_mask | InotifyConstants.IN_OPEN)
+
+ return event_mask
+
+
+class InotifyFullEmitter(InotifyEmitter):
+ """inotify(7)-based event emitter. By default, this class produces move events even if they are not matched
+ Such move events will have a ``None`` value for the unmatched part.
+ """
+
+ def build_and_queue_event(self, event: GroupedInotifyEvent, *, full_events: bool = True) -> None:
+ super().build_and_queue_event(event, full_events=full_events)
+
+
+class InotifyObserver(BaseObserver):
+ """Observer thread that schedules watching directories and dispatches
+ calls to event handlers.
+ """
+
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT, generate_full_events: bool = False) -> None:
+ cls = InotifyFullEmitter if generate_full_events else InotifyEmitter
+ super().__init__(cls, timeout=timeout)
diff --git a/src/mcpstore/utils/watchdog/observers/inotify_c.py b/src/mcpstore/utils/watchdog/observers/inotify_c.py
new file mode 100644
index 00000000..0d435603
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/inotify_c.py
@@ -0,0 +1,624 @@
+from __future__ import annotations
+
+import ctypes
+import ctypes.util
+import errno
+import logging
+import os
+import select
+import struct
+import threading
+import warnings
+from ctypes import c_char_p, c_int, c_uint32
+from dataclasses import dataclass, field
+from functools import reduce
+from typing import TYPE_CHECKING, Callable, ClassVar, NewType, Protocol, cast
+
+from mcpstore.utils.watchdog.utils import BaseThread, UnsupportedLibcError
+
+if TYPE_CHECKING:
+ from collections.abc import Generator, Sequence
+
+logger = logging.getLogger(__name__)
+
+libc = ctypes.CDLL(None)
+
+if not hasattr(libc, "inotify_init") or not hasattr(libc, "inotify_add_watch") or not hasattr(libc, "inotify_rm_watch"):
+ error = f"Unsupported libc version found: {libc._name}" # noqa:SLF001
+ raise UnsupportedLibcError(error)
+
+
+WatchDescriptor = NewType("WatchDescriptor", int)
+Mask = NewType("Mask", int)
+
+inotify_add_watch = cast(
+ Callable[[int, bytes, int], WatchDescriptor],
+ ctypes.CFUNCTYPE(c_int, c_int, c_char_p, c_uint32, use_errno=True)(("inotify_add_watch", libc)),
+)
+
+inotify_rm_watch = cast(
+ Callable[[int, WatchDescriptor], int],
+ ctypes.CFUNCTYPE(c_int, c_int, c_uint32, use_errno=True)(("inotify_rm_watch", libc)),
+)
+
+inotify_init = cast(Callable[[], int], ctypes.CFUNCTYPE(c_int, use_errno=True)(("inotify_init", libc)))
+
+
+class InotifyConstants:
+ # User-space events
+ IN_ACCESS: ClassVar[Mask] = Mask(0x00000001) # File was accessed.
+ IN_MODIFY: ClassVar[Mask] = Mask(0x00000002) # File was modified.
+ IN_ATTRIB: ClassVar[Mask] = Mask(0x00000004) # Meta-data changed.
+ IN_CLOSE_WRITE: ClassVar[Mask] = Mask(0x00000008) # Writable file was closed.
+ IN_CLOSE_NOWRITE: ClassVar[Mask] = Mask(0x00000010) # Unwritable file closed.
+ IN_OPEN: ClassVar[Mask] = Mask(0x00000020) # File was opened.
+ IN_MOVED_FROM: ClassVar[Mask] = Mask(0x00000040) # File was moved from X.
+ IN_MOVED_TO: ClassVar[Mask] = Mask(0x00000080) # File was moved to Y.
+ IN_CREATE: ClassVar[Mask] = Mask(0x00000100) # Subfile was created.
+ IN_DELETE: ClassVar[Mask] = Mask(0x00000200) # Subfile was deleted.
+ IN_DELETE_SELF: ClassVar[Mask] = Mask(0x00000400) # Self was deleted.
+ IN_MOVE_SELF: ClassVar[Mask] = Mask(0x00000800) # Self was moved.
+
+ # Helper user-space events.
+ IN_MOVE: ClassVar[Mask] = Mask(IN_MOVED_FROM | IN_MOVED_TO) # Moves.
+
+ # Events sent by the kernel to a watch.
+ IN_UNMOUNT: ClassVar[Mask] = Mask(0x00002000) # Backing file system was unmounted.
+ IN_Q_OVERFLOW: ClassVar[Mask] = Mask(0x00004000) # Event queued overflowed.
+ IN_IGNORED: ClassVar[Mask] = Mask(0x00008000) # File was ignored.
+
+ # Special flags.
+ IN_ONLYDIR: ClassVar[Mask] = Mask(0x01000000) # Only watch the path if it's a directory.
+ IN_DONT_FOLLOW: ClassVar[Mask] = Mask(0x02000000) # Do not follow a symbolic link.
+ IN_EXCL_UNLINK: ClassVar[Mask] = Mask(0x04000000) # Exclude events on unlinked objects
+ IN_MASK_ADD: ClassVar[Mask] = Mask(0x20000000) # Add to the mask of an existing watch.
+ IN_ISDIR: ClassVar[Mask] = Mask(0x40000000) # Event occurred against directory.
+ IN_ONESHOT: ClassVar[Mask] = Mask(0x80000000) # Only send event once.
+
+ # All user-space events.
+ IN_ALL_EVENTS: ClassVar[Mask] = reduce(
+ lambda x, y: Mask(x | y),
+ [
+ IN_ACCESS,
+ IN_MODIFY,
+ IN_ATTRIB,
+ IN_CLOSE_WRITE,
+ IN_CLOSE_NOWRITE,
+ IN_OPEN,
+ IN_MOVED_FROM,
+ IN_MOVED_TO,
+ IN_DELETE,
+ IN_CREATE,
+ IN_DELETE_SELF,
+ IN_MOVE_SELF,
+ ],
+ )
+
+ # Flags for ``inotify_init1``
+ IN_CLOEXEC: ClassVar[Mask] = Mask(0x02000000)
+ IN_NONBLOCK: ClassVar[Mask] = Mask(0x00004000)
+
+
+INOTIFY_ALL_CONSTANTS: dict[str, Mask] = {
+ name: getattr(InotifyConstants, name)
+ for name in dir(InotifyConstants)
+ if name.startswith("IN_") and name not in {"IN_ALL_EVENTS", "IN_MOVE"}
+}
+
+
+# Watchdog's API cares only about these events.
+WATCHDOG_ALL_EVENTS: Mask = reduce(
+ lambda x, y: Mask(x | y),
+ [
+ InotifyConstants.IN_MODIFY,
+ InotifyConstants.IN_ATTRIB,
+ InotifyConstants.IN_MOVED_FROM,
+ InotifyConstants.IN_MOVED_TO,
+ InotifyConstants.IN_CREATE,
+ InotifyConstants.IN_DELETE,
+ InotifyConstants.IN_DELETE_SELF,
+ InotifyConstants.IN_DONT_FOLLOW,
+ InotifyConstants.IN_CLOSE_WRITE,
+ InotifyConstants.IN_CLOSE_NOWRITE,
+ InotifyConstants.IN_OPEN,
+ ],
+)
+
+
+def _get_mask_string(mask: int) -> str:
+ return "|".join(name for name, c_val in INOTIFY_ALL_CONSTANTS.items() if mask & c_val)
+
+
+class InotifyEventStruct(ctypes.Structure):
+ """Structure representation of the inotify_event structure
+ (used in buffer size calculations)::
+
+ struct inotify_event {
+ __s32 wd; /* watch descriptor */
+ __u32 mask; /* watch mask */
+ __u32 cookie; /* cookie to synchronize two events */
+ __u32 len; /* length (including nulls) of name */
+ char name[0]; /* stub for possible name */
+ };
+ """
+
+ _fields_ = (
+ ("wd", c_int),
+ ("mask", c_uint32),
+ ("cookie", c_uint32),
+ ("len", c_uint32),
+ ("name", c_char_p),
+ )
+
+
+EVENT_SIZE = ctypes.sizeof(InotifyEventStruct)
+DEFAULT_NUM_EVENTS = 2048
+DEFAULT_EVENT_BUFFER_SIZE = DEFAULT_NUM_EVENTS * (EVENT_SIZE + 16)
+
+
+CallbackId = NewType("CallbackId", int)
+
+
+class WatchCallback(Protocol):
+ def on_event(self, event: InotifyEvent) -> None:
+ """called for every event for each watch this callback is registered at."""
+ ...
+
+ def on_watch_deleted(self, wd: WatchDescriptor) -> None:
+ """Called when a watch that ths callback is registered at is removed.
+ This is the case when the watched object is deleted."""
+ ...
+
+
+@dataclass
+class Watch:
+ """Represents an inotify watch"""
+
+ wd: WatchDescriptor
+ """the inotify watch descriptor"""
+ mask: Mask
+ """the mask used"""
+ _initial_creation_path: bytes
+ """the original(!) path being watched.
+ .. NOTE:: Do **NOT** use when creating or interpreting events, finding watches
+ or similar. This is purely meant to help debugging.
+
+ If a watched file/folder gets moved and we create a new watch for the
+ file/folder at the new path, inotify will give us the same watch descriptor,
+ which is still remembered undr the old path.
+ """
+ callbacks: dict[CallbackId, WatchCallback] = field(default_factory=dict, init=False, compare=False)
+ """callbacks to be called when an event for this watch is fired. dict[, Callback]"""
+
+ @property
+ def is_used(self) -> bool:
+ return bool(self.callbacks)
+
+ def short_str(self) -> str:
+ contents = ", ".join(
+ [
+ f"wd={self.wd}",
+ f"mask={_get_mask_string(self.mask)}",
+ f"_initial_creation_path={self._initial_creation_path!r}",
+ ]
+ )
+ return f"<{type(self).__name__}: {contents}>"
+
+
+class InotifyFD(BaseThread):
+ """Linux inotify(7) API wrapper class.
+ Allows adding and removing callbacks to specific inotify watches, keeps
+ track of them, and automatically calls the appropriate callbacks for each
+ event.
+
+ Watches are created and removed as needed.
+ """
+
+ # InotifyFD is a singleton for now.
+ _instance: ClassVar[InotifyFD | None] = None
+ _global_lock: ClassVar[threading.Lock] = threading.Lock()
+
+ def __init__(self) -> None:
+ super().__init__()
+ if hasattr(self, "is_initialized"):
+ return # do not initialize the singleton twice.
+ self.is_initialized = True
+
+ # The file descriptor associated with the inotify instance.
+ self._inotify_fd: int = self._create_inotify_fd()
+
+ self._lock = threading.Lock()
+ self._closed = False
+ self._is_reading = True
+ self._kill_r, self._kill_w = os.pipe()
+
+ # used by _check_inotify_fd to tell if we can read _inotify_fd without blocking
+ if hasattr(select, "poll"):
+ self._poller: select.poll | None = select.poll()
+ self._poller.register(self._inotify_fd, select.POLLIN)
+ self._poller.register(self._kill_r, select.POLLIN)
+ else:
+ self._poller = None
+
+ # Stores the callbacks for a given watch descriptor.
+ self._watch_for_wd: dict[WatchDescriptor, Watch] = {}
+
+ @classmethod
+ def _create_inotify_fd(cls) -> int:
+ inotify_fd = inotify_init()
+ if inotify_fd == -1:
+ InotifyFD._raise_error()
+ return inotify_fd
+
+ @classmethod
+ def get_instance(cls) -> InotifyFD:
+ """Use this class method to get a running InotifyFD instance."""
+ with cls._global_lock:
+ # enforce that InotifyFD is a singleton.
+ if cls._instance is None:
+ cls._instance = InotifyFD()
+ cls._instance.start()
+ return cls._instance
+
+ def add_callback(self, path: bytes, mask: Mask, callback: WatchCallback, id_: CallbackId) -> WatchDescriptor:
+ """Adds a callback for the given path to monitor events specified by the
+ mask. If a watch already exists for the given path, it is reused.
+ If a callback with the given id_ already exists for this watch, it is overwritten and a warning is generated.
+
+ :param path:
+ Path to begin monitoring.
+ :param mask:
+ Event bit mask.
+ :param callback:
+ Function to be called when an event for this watch is fired
+ :param id_:
+ Some form of id usd to identify the callback (for example to remove it later on...).
+ The id must be unique only within a given watch.
+ """
+ with self._lock:
+ return self._add_callback(path, mask, callback, id_)
+
+ def remove_callbacks(self, callbacks: list[tuple[WatchDescriptor, CallbackId]]) -> None:
+ """Removes callbacks from WatchDescriptors. If a callback was the last
+ callback on a watch, the watch is removed. Otherwise, just the callback
+ is removed from the watch.
+
+ If no watch for a given WatchDescriptor exists or no callback with the
+ given id_ for the watch exists, a warning is generated.
+
+ Implementation Note:
+ This does not use the path to identify a watch, because the _actual_
+ path of a watch can change if the watched file/folder is moved.
+
+ :param callbacks:
+ a list of (WatchDescriptor, callback id)-tuples for each of which
+ the callback will be removed from the WatchDescriptor.
+ """
+ with self._lock:
+ for wd, id_ in callbacks:
+ self._remove_callback(wd, id_)
+
+ def on_thread_stop(self) -> None:
+ self.close()
+
+ def run(self) -> None:
+ """Read events from `inotify` and handle them."""
+ while self.should_keep_running():
+ inotify_events = self.read_events()
+ for event in inotify_events:
+ self.handle_event(event)
+
+ def close(self) -> None:
+ """Closes the inotify instance and removes all associated watches."""
+ delete_callbacks = []
+ with self._lock:
+ if not self._closed:
+ self._closed = True
+ for wd in self._watch_for_wd.copy():
+ inotify_rm_watch(self._inotify_fd, wd)
+ delete_callbacks.append((wd, self._remove_watch(wd)))
+ self._watch_for_wd.clear()
+
+ if self._is_reading:
+ # inotify_rm_watch() should write data to _inotify_fd and wake
+ # the thread, but writing to the kill channel will guarantee this
+ os.write(self._kill_w, b"!")
+ else:
+ self._close_resources()
+
+ # execute callbacks outside of lock, as they might attempt to register / unregister watches:
+ for wd, callbacks in delete_callbacks:
+ for callback in callbacks:
+ callback.on_watch_deleted(wd)
+
+ def handle_event(self, event: InotifyEvent) -> None:
+ with self._lock:
+ if event.is_ignored:
+ # Clean up book-keeping for deleted watches.
+ delete_callbacks: Sequence[WatchCallback] = self._remove_watch(event.wd)
+ callbacks: Sequence[WatchCallback] = ()
+ else:
+ delete_callbacks = ()
+ watch = self._watch_for_wd.get(event.wd)
+
+ # watch might have been removed already. Also copy, because
+ # watch.callbacks might change during later iteration
+ callbacks = list(watch.callbacks.values()) if watch is not None else ()
+
+ # execute callbacks outside of lock, as they might need to register / unregister watches:
+ for callback in callbacks:
+ callback.on_event(event)
+ for callback in delete_callbacks:
+ callback.on_watch_deleted(event.wd)
+
+ def read_events(self, *, event_buffer_size: int = DEFAULT_EVENT_BUFFER_SIZE) -> list[InotifyEvent]:
+ """
+ Reads events from inotify and yields them.
+ All appropriate exiting watches are automatically moved when a move event occurs.
+ """
+ event_buffer = self._read_event_buffer(event_buffer_size)
+ return [
+ InotifyEvent(wd, mask, cookie, name)
+ for wd, mask, cookie, name in InotifyFD._parse_event_buffer(event_buffer)
+ if wd != -1
+ ]
+
+ # Non-synchronized methods.
+
+ def _check_inotify_fd(self) -> bool:
+ """return true if we can read _inotify_fd without blocking"""
+ if self._poller is not None:
+ return any(fd == self._inotify_fd for fd, _ in self._poller.poll())
+
+ result = select.select([self._inotify_fd, self._kill_r], [], [])
+ return self._inotify_fd in result[0]
+
+ def _read_event_buffer(self, event_buffer_size: int) -> bytes:
+ """
+ Reads from inotify and returns what was read.
+ If inotify got closed or if an errno.EBADF occurred during reading, None is returned.
+ """
+ event_buffer = b""
+ while True:
+ try:
+ with self._lock:
+ if self._closed:
+ return b""
+
+ self._is_reading = True
+
+ if self._check_inotify_fd():
+ event_buffer = os.read(self._inotify_fd, event_buffer_size)
+
+ with self._lock:
+ self._is_reading = False
+
+ if self._closed:
+ self._close_resources()
+ return b""
+ except OSError as e:
+ if e.errno == errno.EINTR:
+ continue
+
+ if e.errno == errno.EBADF:
+ return b""
+
+ raise
+ break
+ return event_buffer
+
+ def _close_resources(self) -> None:
+ os.close(self._inotify_fd)
+ os.close(self._kill_r)
+ os.close(self._kill_w)
+
+ def _add_callback(self, path: bytes, mask: Mask, callback: WatchCallback, id_: CallbackId) -> WatchDescriptor:
+ """Adds a callback for the given path to monitor events specified by the
+ mask. If a watch already exists for the given path, it is reused.
+ If a callback with the given id_ already exists for this watch, it is overwritten and a warning is generated.
+
+ :param path:
+ Path to begin monitoring.
+ :param mask:
+ Event bit mask.
+ :param callback:
+ Function to be called when an event for this watch is fired
+ :param id_:
+ Some form of id usd to identify the callback (for example to remove it later on...).
+ The id must be unique only within a given watch.
+ """
+ watch = self._get_or_create_watch(path, mask)
+
+ if id_ in watch.callbacks:
+ msg = f"Callback with id '{id_}' already exists for watch {watch.short_str}. It will be Overwritten."
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
+ watch.callbacks[id_] = callback
+ return watch.wd
+
+ def _get_or_create_watch(self, path: bytes, mask: Mask) -> Watch:
+ """Creates a watch for the given path to monitor events specified by the
+ mask.
+
+ :param path:
+ Path to monitor
+ :param mask:
+ Event bit mask.
+ """
+ # returns an existing watch descriptor, if one already exists for path:
+ wd = inotify_add_watch(self._inotify_fd, path, mask)
+ if wd == -1:
+ InotifyFD._raise_error()
+ watch = self._watch_for_wd.get(wd)
+ if watch is None:
+ watch = Watch(wd, mask, path)
+ self._watch_for_wd[wd] = watch
+ return watch
+
+ def _remove_callback(self, wd: WatchDescriptor, id_: CallbackId) -> None:
+ """Removes a callback for the given WatchDescriptor. If it was the last callback on
+ the watch, the watch is removed. Otherwise, just the callback is removed
+ from the watch.
+ If no watch for the given WatchDescriptor exists or no callback with the given id_ for the watch exists, a
+ warning is generated.
+
+ :param wd:
+ WatchDescriptor for which the callback will be removed.
+ :param id_:
+ Some form of id usd to identify the callback.
+ """
+ watch = self._watch_for_wd.get(wd)
+ if watch is None:
+ msg = "Trying to remove callback from a watch that does not exist. WatchDescriptor: %s, callback id: '%s'."
+ logger.debug(msg, wd, id_)
+ return
+
+ if watch.callbacks.pop(id_, None) is None:
+ msg = f"Callback with id '{id_}' does not exist for watch {watch.short_str} and therefore cannot be removed"
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
+
+ if not watch.is_used:
+ delete_callbacks = self._remove_watch(watch.wd)
+ if inotify_rm_watch(self._inotify_fd, watch.wd) == -1:
+ InotifyFD._raise_error(ignore_invalid_argument=True) # ignore if a watch doesn't exist anymore
+ assert not delete_callbacks, f"delete_callbacks should be empty, but was: {delete_callbacks}"
+
+ def _remove_watch(self, wd: WatchDescriptor) -> Sequence[WatchCallback]:
+ """Notifies all necessary objects of deleted watches and cleans up book-keeping.
+ This does NOT call inotify_rm_watch."""
+ watch = self._watch_for_wd.pop(wd, None)
+ return list(watch.callbacks.values()) if watch is not None else []
+
+ @staticmethod
+ def _raise_error(*, ignore_invalid_argument: bool = False) -> None:
+ """Raises errors for inotify failures."""
+ err = ctypes.get_errno()
+
+ if err == errno.ENOSPC:
+ raise OSError(errno.ENOSPC, "inotify watch limit reached")
+
+ if err == errno.EMFILE:
+ raise OSError(errno.EMFILE, "inotify instance limit reached")
+
+ if ignore_invalid_argument and err == errno.EINVAL:
+ return # ignore
+
+ if err != errno.EACCES:
+ raise OSError(err, os.strerror(err))
+
+ @staticmethod
+ def _parse_event_buffer(event_buffer: bytes) -> Generator[tuple[WatchDescriptor, Mask, int, bytes]]:
+ """Parses an event buffer of ``inotify_event`` structs returned by
+ inotify::
+
+ struct inotify_event {
+ __s32 wd; /* watch descriptor */
+ __u32 mask; /* watch mask */
+ __u32 cookie; /* cookie to synchronize two events */
+ __u32 len; /* length (including nulls) of name */
+ char name[0]; /* stub for possible name */
+ };
+
+ The ``cookie`` member of this struct is used to pair two related
+ events, for example, it pairs an IN_MOVED_FROM event with an
+ IN_MOVED_TO event.
+ """
+ i = 0
+ while i + 16 <= len(event_buffer):
+ wd, mask, cookie, length = struct.unpack_from("iIII", event_buffer, i)
+ name = event_buffer[i + 16 : i + 16 + length].rstrip(b"\0")
+ i += 16 + length
+ yield wd, mask, cookie, name
+
+
+# creates global InotifyFD instance NOW, (only necessary for unit tests) todo find better solution
+InotifyFD.get_instance()
+
+
+@dataclass(unsafe_hash=True, frozen=True)
+class InotifyEvent:
+ """Inotify event struct wrapper."""
+
+ wd: WatchDescriptor
+ """Watch descriptor"""
+ mask: Mask
+ """Event mask"""
+ cookie: int
+ """Event cookie"""
+ name: bytes
+ """Base name of the event source path. might be empty"""
+ # src_path: bytes; We cannot set the src_path.
+ # See 'Challenges With inotify' section in the description of inotify.py
+
+ @property
+ def is_modify(self) -> bool:
+ return self.mask & InotifyConstants.IN_MODIFY > 0
+
+ @property
+ def is_close_write(self) -> bool:
+ return self.mask & InotifyConstants.IN_CLOSE_WRITE > 0
+
+ @property
+ def is_close_nowrite(self) -> bool:
+ return self.mask & InotifyConstants.IN_CLOSE_NOWRITE > 0
+
+ @property
+ def is_open(self) -> bool:
+ return self.mask & InotifyConstants.IN_OPEN > 0
+
+ @property
+ def is_access(self) -> bool:
+ return self.mask & InotifyConstants.IN_ACCESS > 0
+
+ @property
+ def is_delete(self) -> bool:
+ return self.mask & InotifyConstants.IN_DELETE > 0
+
+ @property
+ def is_delete_self(self) -> bool:
+ return self.mask & InotifyConstants.IN_DELETE_SELF > 0
+
+ @property
+ def is_create(self) -> bool:
+ return self.mask & InotifyConstants.IN_CREATE > 0
+
+ @property
+ def is_moved_from(self) -> bool:
+ return self.mask & InotifyConstants.IN_MOVED_FROM > 0
+
+ @property
+ def is_moved_to(self) -> bool:
+ return self.mask & InotifyConstants.IN_MOVED_TO > 0
+
+ @property
+ def is_move(self) -> bool:
+ return self.mask & InotifyConstants.IN_MOVE > 0
+
+ @property
+ def is_move_self(self) -> bool:
+ return self.mask & InotifyConstants.IN_MOVE_SELF > 0
+
+ @property
+ def is_attrib(self) -> bool:
+ return self.mask & InotifyConstants.IN_ATTRIB > 0
+
+ @property
+ def is_ignored(self) -> bool:
+ return self.mask & InotifyConstants.IN_IGNORED > 0
+
+ @property
+ def is_directory(self) -> bool:
+ # It looks like the kernel does not provide this information for
+ # IN_DELETE_SELF and IN_MOVE_SELF. In this case, assume it's a dir.
+ # See also: https://github.com/seb-m/pyinotify/blob/2c7e8f8/python2/pyinotify.py#L897
+ return self.is_delete_self or self.is_move_self or self.mask & InotifyConstants.IN_ISDIR > 0
+
+ def __repr__(self) -> str:
+ contents = ", ".join(
+ [
+ f"wd={self.wd}",
+ f"mask={_get_mask_string(self.mask)}",
+ f"cookie={self.cookie}",
+ f"name={os.fsdecode(self.name)!r}",
+ ]
+ )
+ return f"<{type(self).__name__}: {contents}>"
diff --git a/src/mcpstore/utils/watchdog/observers/inotify_move_event_grouper.py b/src/mcpstore/utils/watchdog/observers/inotify_move_event_grouper.py
new file mode 100644
index 00000000..519b1275
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/inotify_move_event_grouper.py
@@ -0,0 +1,91 @@
+""":module: watchdog.observers.inotify_buffer
+:synopsis: queue-like class for ``Inotify`` to group move events.
+:author: thomas.amland@gmail.com (Thomas Amland)
+:author: Mickaël Schoentgen
+:author: Joachim Coenen
+:platforms: linux 2.6.13+
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, NamedTuple, Union, cast
+
+from mcpstore.utils.watchdog.utils.delayed_queue import DelayedQueue
+
+if TYPE_CHECKING:
+ from typing import TypeAlias
+
+ from mcpstore.utils.watchdog.observers.inotify_c import InotifyEvent
+
+logger = logging.getLogger(__name__)
+
+
+class PathedInotifyEvent(NamedTuple):
+ """An InotifyEvent and its full source path"""
+
+ ev: InotifyEvent
+ path: bytes
+
+
+GroupedInotifyEvent: TypeAlias = Union[PathedInotifyEvent, tuple[PathedInotifyEvent, PathedInotifyEvent]]
+
+
+class InotifyMoveEventGrouper:
+ """A queue-like class for `Inotify` that holds IN_MOVE_FROM events for
+ `delay` seconds. During this time, IN_MOVED_FROM and IN_MOVED_TO events are
+ paired.
+ """
+
+ delay = 0.5
+
+ def __init__(self) -> None:
+ self._queue: DelayedQueue[GroupedInotifyEvent] = DelayedQueue(self.delay)
+
+ def read_event(self) -> GroupedInotifyEvent | None:
+ """Returns a single event or a tuple of from/to events in case of a
+ paired move event. If this buffer has been closed, raise the Closed
+ exception.
+ """
+ return self._queue.get()
+
+ def put_event(self, event: PathedInotifyEvent) -> None:
+ """Add an event to the `queue`. When adding an IN_MOVE_TO event, remove
+ the previous added matching IN_MOVE_FROM event and add them back to the
+ queue as a tuple.
+ """
+ logger.debug("in-event %s", event)
+ # Only add delay for unmatched move_from events
+ should_delay = event.ev.is_moved_from
+
+ grouped_event = self._group_moved_to_event(event) if event.ev.is_moved_to else event
+
+ self._queue.put(grouped_event, delay=should_delay)
+
+ def _group_moved_to_event(self, to_event: PathedInotifyEvent) -> GroupedInotifyEvent:
+ """Group any matching move events by checking if a matching move_from is
+ in delay queue already and removing it"""
+ cookie = to_event.ev.cookie
+
+ def matching_from_event(event: GroupedInotifyEvent) -> bool:
+ return isinstance(event, PathedInotifyEvent) and event.ev.is_moved_from and event.ev.cookie == cookie
+
+ # Check if move_from is in delayqueue already
+ from_event = cast(PathedInotifyEvent, self._queue.remove(matching_from_event))
+ if from_event is None:
+ logger.debug("could not find matching move_from event")
+
+ return (from_event, to_event) if from_event is not None else to_event
+
+ def get_queued_moved_from_event(self, cookie: int) -> PathedInotifyEvent | None:
+ """Finds a queued IN_MOVED_FROM event with the give cookie, but does not
+ remove it."""
+
+ def matching_from_event(event: GroupedInotifyEvent) -> bool:
+ return isinstance(event, PathedInotifyEvent) and event.ev.is_moved_from and event.ev.cookie == cookie
+
+ return cast(PathedInotifyEvent, self._queue.find(matching_from_event))
+
+ def close(self) -> None:
+ """closes the queue"""
+ self._queue.close()
diff --git a/src/mcpstore/utils/watchdog/observers/kqueue.py b/src/mcpstore/utils/watchdog/observers/kqueue.py
new file mode 100644
index 00000000..26840cc3
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/kqueue.py
@@ -0,0 +1,656 @@
+""":module: watchdog.observers.kqueue
+:synopsis: ``kqueue(2)`` based emitter implementation.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+:platforms: macOS and BSD with kqueue(2).
+
+.. WARNING:: kqueue is a very heavyweight way to monitor file systems.
+ Each kqueue-detected directory modification triggers
+ a full directory scan. Traversing the entire directory tree
+ and opening file descriptors for all files will create
+ performance problems. We need to find a way to re-scan
+ only those directories which report changes and do a diff
+ between two sub-DirectorySnapshots perhaps.
+
+.. ADMONITION:: About OS X performance guidelines
+
+ Quote from the `macOS File System Performance Guidelines`_:
+
+ "When you only want to track changes on a file or directory, be sure to
+ open it using the ``O_EVTONLY`` flag. This flag prevents the file or
+ directory from being marked as open or in use. This is important
+ if you are tracking files on a removable volume and the user tries to
+ unmount the volume. With this flag in place, the system knows it can
+ dismiss the volume. If you had opened the files or directories without
+ this flag, the volume would be marked as busy and would not be
+ unmounted."
+
+ ``O_EVTONLY`` is defined as ``0x8000`` in the OS X header files.
+ More information here: http://www.mlsite.net/blog/?p=2312
+
+Classes
+-------
+.. autoclass:: KqueueEmitter
+ :members:
+ :show-inheritance:
+
+Collections and Utility Classes
+-------------------------------
+.. autoclass:: KeventDescriptor
+ :members:
+ :show-inheritance:
+
+.. autoclass:: KeventDescriptorSet
+ :members:
+ :show-inheritance:
+
+.. _macOS File System Performance Guidelines:
+ http://developer.apple.com/library/ios/#documentation/Performance/Conceptual/FileSystem/Articles/TrackingChanges.html#//apple_ref/doc/uid/20001993-CJBJFIDD
+
+"""
+
+
+# The `select` module varies between platforms.
+# mypy may complain about missing module attributes depending on which platform it's running on.
+# The comment below disables mypy's attribute check.
+# mypy: disable-error-code="attr-defined, name-defined"
+
+from __future__ import annotations
+
+import contextlib
+import errno
+import os
+import os.path
+import select
+import threading
+from stat import S_ISDIR
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.events import (
+ EVENT_TYPE_CREATED,
+ EVENT_TYPE_DELETED,
+ EVENT_TYPE_MOVED,
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ generate_sub_moved_events,
+)
+from mcpstore.utils.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, \
+ EventEmitter
+from mcpstore.utils.watchdog.utils import platform
+from mcpstore.utils.watchdog.utils.dirsnapshot import DirectorySnapshot
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+ from typing import Callable
+
+ from mcpstore.utils.watchdog.events import FileSystemEvent
+ from mcpstore.utils.watchdog.observers.api import EventQueue, ObservedWatch
+
+# Maximum number of events to process.
+MAX_EVENTS = 4096
+
+# O_EVTONLY value from the header files for OS X only.
+O_EVTONLY = 0x8000
+
+# Pre-calculated values for the kevent filter, flags, and fflags attributes.
+WATCHDOG_OS_OPEN_FLAGS = O_EVTONLY if platform.is_darwin() else os.O_RDONLY | os.O_NONBLOCK
+WATCHDOG_KQ_FILTER = select.KQ_FILTER_VNODE
+WATCHDOG_KQ_EV_FLAGS = select.KQ_EV_ADD | select.KQ_EV_ENABLE | select.KQ_EV_CLEAR
+WATCHDOG_KQ_FFLAGS = (
+ select.KQ_NOTE_DELETE
+ | select.KQ_NOTE_WRITE
+ | select.KQ_NOTE_EXTEND
+ | select.KQ_NOTE_ATTRIB
+ | select.KQ_NOTE_LINK
+ | select.KQ_NOTE_RENAME
+ | select.KQ_NOTE_REVOKE
+)
+
+
+def absolute_path(path: bytes | str) -> bytes | str:
+ return os.path.abspath(os.path.normpath(path))
+
+
+# Flag tests.
+
+
+def is_deleted(kev: select.kevent) -> bool:
+ """Determines whether the given kevent represents deletion."""
+ return kev.fflags & select.KQ_NOTE_DELETE > 0
+
+
+def is_modified(kev: select.kevent) -> bool:
+ """Determines whether the given kevent represents modification."""
+ fflags = kev.fflags
+ return (fflags & select.KQ_NOTE_EXTEND > 0) or (fflags & select.KQ_NOTE_WRITE > 0)
+
+
+def is_attrib_modified(kev: select.kevent) -> bool:
+ """Determines whether the given kevent represents attribute modification."""
+ return kev.fflags & select.KQ_NOTE_ATTRIB > 0
+
+
+def is_renamed(kev: select.kevent) -> bool:
+ """Determines whether the given kevent represents movement."""
+ return kev.fflags & select.KQ_NOTE_RENAME > 0
+
+
+class KeventDescriptorSet:
+ """Thread-safe kevent descriptor collection."""
+
+ def __init__(self) -> None:
+ self._descriptors: set[KeventDescriptor] = set()
+ self._descriptor_for_path: dict[bytes | str, KeventDescriptor] = {}
+ self._descriptor_for_fd: dict[int, KeventDescriptor] = {}
+ self._kevents: list[select.kevent] = []
+ self._lock = threading.Lock()
+
+ @property
+ def kevents(self) -> list[select.kevent]:
+ """List of kevents monitored."""
+ with self._lock:
+ return self._kevents
+
+ @property
+ def paths(self) -> list[bytes | str]:
+ """List of paths for which kevents have been created."""
+ with self._lock:
+ return list(self._descriptor_for_path.keys())
+
+ def get_for_fd(self, fd: int) -> KeventDescriptor:
+ """Given a file descriptor, returns the kevent descriptor object
+ for it.
+
+ :param fd:
+ OS file descriptor.
+ :type fd:
+ ``int``
+ :returns:
+ A :class:`KeventDescriptor` object.
+ """
+ with self._lock:
+ return self._descriptor_for_fd[fd]
+
+ def get(self, path: bytes | str) -> KeventDescriptor:
+ """Obtains a :class:`KeventDescriptor` object for the specified path.
+
+ :param path:
+ Path for which the descriptor will be obtained.
+ """
+ with self._lock:
+ path = absolute_path(path)
+ return self._get(path)
+
+ def __contains__(self, path: bytes | str) -> bool:
+ """Determines whether a :class:`KeventDescriptor has been registered
+ for the specified path.
+
+ :param path:
+ Path for which the descriptor will be obtained.
+ """
+ with self._lock:
+ path = absolute_path(path)
+ return self._has_path(path)
+
+ def add(self, path: bytes | str, *, is_directory: bool) -> None:
+ """Adds a :class:`KeventDescriptor` to the collection for the given
+ path.
+
+ :param path:
+ The path for which a :class:`KeventDescriptor` object will be
+ added.
+ :param is_directory:
+ ``True`` if the path refers to a directory; ``False`` otherwise.
+ :type is_directory:
+ ``bool``
+ """
+ with self._lock:
+ path = absolute_path(path)
+ if not self._has_path(path):
+ self._add_descriptor(KeventDescriptor(path, is_directory=is_directory))
+
+ def remove(self, path: bytes | str) -> None:
+ """Removes the :class:`KeventDescriptor` object for the given path
+ if it already exists.
+
+ :param path:
+ Path for which the :class:`KeventDescriptor` object will be
+ removed.
+ """
+ with self._lock:
+ path = absolute_path(path)
+ if self._has_path(path):
+ self._remove_descriptor(self._get(path))
+
+ def clear(self) -> None:
+ """Clears the collection and closes all open descriptors."""
+ with self._lock:
+ for descriptor in self._descriptors:
+ descriptor.close()
+ self._descriptors.clear()
+ self._descriptor_for_fd.clear()
+ self._descriptor_for_path.clear()
+ self._kevents = []
+
+ # Thread-unsafe methods. Locking is provided at a higher level.
+ def _get(self, path: bytes | str) -> KeventDescriptor:
+ """Returns a kevent descriptor for a given path."""
+ return self._descriptor_for_path[path]
+
+ def _has_path(self, path: bytes | str) -> bool:
+ """Determines whether a :class:`KeventDescriptor` for the specified
+ path exists already in the collection.
+ """
+ return path in self._descriptor_for_path
+
+ def _add_descriptor(self, descriptor: KeventDescriptor) -> None:
+ """Adds a descriptor to the collection.
+
+ :param descriptor:
+ An instance of :class:`KeventDescriptor` to be added.
+ """
+ self._descriptors.add(descriptor)
+ self._kevents.append(descriptor.kevent)
+ self._descriptor_for_path[descriptor.path] = descriptor
+ self._descriptor_for_fd[descriptor.fd] = descriptor
+
+ def _remove_descriptor(self, descriptor: KeventDescriptor) -> None:
+ """Removes a descriptor from the collection.
+
+ :param descriptor:
+ An instance of :class:`KeventDescriptor` to be removed.
+ """
+ self._descriptors.remove(descriptor)
+ del self._descriptor_for_fd[descriptor.fd]
+ del self._descriptor_for_path[descriptor.path]
+ self._kevents.remove(descriptor.kevent)
+ descriptor.close()
+
+
+class KeventDescriptor:
+ """A kevent descriptor convenience data structure to keep together:
+
+ * kevent
+ * directory status
+ * path
+ * file descriptor
+
+ :param path:
+ Path string for which a kevent descriptor will be created.
+ :param is_directory:
+ ``True`` if the path refers to a directory; ``False`` otherwise.
+ :type is_directory:
+ ``bool``
+ """
+
+ def __init__(self, path: bytes | str, *, is_directory: bool) -> None:
+ self._path = absolute_path(path)
+ self._is_directory = is_directory
+ self._fd = os.open(path, WATCHDOG_OS_OPEN_FLAGS)
+ self._kev = select.kevent(
+ self._fd,
+ filter=WATCHDOG_KQ_FILTER,
+ flags=WATCHDOG_KQ_EV_FLAGS,
+ fflags=WATCHDOG_KQ_FFLAGS,
+ )
+
+ @property
+ def fd(self) -> int:
+ """OS file descriptor for the kevent descriptor."""
+ return self._fd
+
+ @property
+ def path(self) -> bytes | str:
+ """The path associated with the kevent descriptor."""
+ return self._path
+
+ @property
+ def kevent(self) -> select.kevent:
+ """The kevent object associated with the kevent descriptor."""
+ return self._kev
+
+ @property
+ def is_directory(self) -> bool:
+ """Determines whether the kevent descriptor refers to a directory.
+
+ :returns:
+ ``True`` or ``False``
+ """
+ return self._is_directory
+
+ def close(self) -> None:
+ """Closes the file descriptor associated with a kevent descriptor."""
+ with contextlib.suppress(OSError):
+ os.close(self.fd)
+
+ @property
+ def key(self) -> tuple[bytes | str, bool]:
+ return (self.path, self.is_directory)
+
+ def __eq__(self, descriptor: object) -> bool:
+ if not isinstance(descriptor, KeventDescriptor):
+ return NotImplemented
+ return self.key == descriptor.key
+
+ def __ne__(self, descriptor: object) -> bool:
+ if not isinstance(descriptor, KeventDescriptor):
+ return NotImplemented
+ return self.key != descriptor.key
+
+ def __hash__(self) -> int:
+ return hash(self.key)
+
+ def __repr__(self) -> str:
+ return f"<{type(self).__name__}: path={self.path!r}, is_directory={self.is_directory}>"
+
+
+class KqueueEmitter(EventEmitter):
+ """kqueue(2)-based event emitter.
+
+ .. ADMONITION:: About ``kqueue(2)`` behavior and this implementation
+
+ ``kqueue(2)`` monitors file system events only for
+ open descriptors, which means, this emitter does a lot of
+ book-keeping behind the scenes to keep track of open
+ descriptors for every entry in the monitored directory tree.
+
+ This also means the number of maximum open file descriptors
+ on your system must be increased **manually**.
+ Usually, issuing a call to ``ulimit`` should suffice::
+
+ ulimit -n 1024
+
+ Ensure that you pick a number that is larger than the
+ number of files you expect to be monitored.
+
+ ``kqueue(2)`` does not provide enough information about the
+ following things:
+
+ * The destination path of a file or directory that is renamed.
+ * Creation of a file or directory within a directory; in this
+ case, ``kqueue(2)`` only indicates a modified event on the
+ parent directory.
+
+ Therefore, this emitter takes a snapshot of the directory
+ tree when ``kqueue(2)`` detects a change on the file system
+ to be able to determine the above information.
+
+ :param event_queue:
+ The event queue to fill with events.
+ :param watch:
+ A watch object representing the directory to monitor.
+ :type watch:
+ :class:`watchdog.observers.api.ObservedWatch`
+ :param timeout:
+ Read events blocking timeout (in seconds).
+ :type timeout:
+ ``float``
+ :param event_filter:
+ Collection of event types to emit, or None for no filtering (default).
+ :type event_filter:
+ Iterable[:class:`watchdog.events.FileSystemEvent`] | None
+ :param stat: stat function. See ``os.stat`` for details.
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ stat: Callable[[str], os.stat_result] = os.stat,
+ ) -> None:
+ super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
+
+ self._kq = select.kqueue()
+ self._lock = threading.RLock()
+
+ # A collection of KeventDescriptor.
+ self._descriptors = KeventDescriptorSet()
+
+ def custom_stat(path: str, cls: KqueueEmitter = self) -> os.stat_result:
+ stat_info = stat(path)
+ cls._register_kevent(path, is_directory=S_ISDIR(stat_info.st_mode))
+ return stat_info
+
+ self._snapshot = DirectorySnapshot(watch.path, recursive=watch.is_recursive, stat=custom_stat)
+
+ def _register_kevent(self, path: bytes | str, *, is_directory: bool) -> None:
+ """Registers a kevent descriptor for the given path.
+
+ :param path:
+ Path for which a kevent descriptor will be created.
+ :param is_directory:
+ ``True`` if the path refers to a directory; ``False`` otherwise.
+ :type is_directory:
+ ``bool``
+ """
+ try:
+ self._descriptors.add(path, is_directory=is_directory)
+ except OSError as e:
+ if e.errno == errno.ENOENT:
+ # Probably dealing with a temporary file that was created
+ # and then quickly deleted before we could open
+ # a descriptor for it. Therefore, simply queue a sequence
+ # of created and deleted events for the path.
+
+ # TODO: We could simply ignore these files.
+ # Locked files cause the python process to die with
+ # a bus error when we handle temporary files.
+ # eg. .git/index.lock when running tig operations.
+ # I don't fully understand this at the moment.
+ pass
+ elif e.errno == errno.EOPNOTSUPP:
+ # Probably dealing with the socket or special file
+ # mounted through a file system that does not support
+ # access to it (e.g. NFS). On BSD systems look at
+ # EOPNOTSUPP in man 2 open.
+ pass
+ else:
+ # All other errors are propagated.
+ raise
+
+ def _unregister_kevent(self, path: bytes | str) -> None:
+ """Convenience function to close the kevent descriptor for a
+ specified kqueue-monitored path.
+
+ :param path:
+ Path for which the kevent descriptor will be closed.
+ """
+ self._descriptors.remove(path)
+
+ def queue_event(self, event: FileSystemEvent) -> None:
+ """Handles queueing a single event object.
+
+ :param event:
+ An instance of :class:`watchdog.events.FileSystemEvent`
+ or a subclass.
+ """
+ # Handles all the book keeping for queued events.
+ # We do not need to fire moved/deleted events for all subitems in
+ # a directory tree here, because this function is called by kqueue
+ # for all those events anyway.
+ EventEmitter.queue_event(self, event)
+ if event.event_type == EVENT_TYPE_CREATED:
+ self._register_kevent(event.src_path, is_directory=event.is_directory)
+ elif event.event_type == EVENT_TYPE_MOVED:
+ self._unregister_kevent(event.src_path)
+ self._register_kevent(event.dest_path, is_directory=event.is_directory)
+ elif event.event_type == EVENT_TYPE_DELETED:
+ self._unregister_kevent(event.src_path)
+
+ def _gen_kqueue_events(
+ self, kev: select.kevent, ref_snapshot: DirectorySnapshot, new_snapshot: DirectorySnapshot
+ ) -> Generator[FileSystemEvent]:
+ """Generate events from the kevent list returned from the call to
+ :meth:`select.kqueue.control`.
+
+ .. NOTE:: kqueue only tells us about deletions, file modifications,
+ attribute modifications. The other events, namely,
+ file creation, directory modification, file rename,
+ directory rename, directory creation, etc. are
+ determined by comparing directory snapshots.
+ """
+ descriptor = self._descriptors.get_for_fd(kev.ident)
+ src_path = descriptor.path
+
+ if is_renamed(kev):
+ # Kqueue does not specify the destination names for renames
+ # to, so we have to process these using the a snapshot
+ # of the directory.
+ yield from self._gen_renamed_events(
+ src_path,
+ ref_snapshot,
+ new_snapshot,
+ is_directory=descriptor.is_directory,
+ )
+ elif is_attrib_modified(kev):
+ if descriptor.is_directory:
+ yield DirModifiedEvent(src_path)
+ else:
+ yield FileModifiedEvent(src_path)
+ elif is_modified(kev):
+ if descriptor.is_directory:
+ if self.watch.is_recursive or self.watch.path == src_path:
+ # When a directory is modified, it may be due to
+ # sub-file/directory renames or new file/directory
+ # creation. We determine all this by comparing
+ # snapshots later.
+ yield DirModifiedEvent(src_path)
+ else:
+ yield FileModifiedEvent(src_path)
+ elif is_deleted(kev):
+ if descriptor.is_directory:
+ yield DirDeletedEvent(src_path)
+ else:
+ yield FileDeletedEvent(src_path)
+
+ def _parent_dir_modified(self, src_path: bytes | str) -> DirModifiedEvent:
+ """Helper to generate a DirModifiedEvent on the parent of src_path."""
+ return DirModifiedEvent(os.path.dirname(src_path))
+
+ def _gen_renamed_events(
+ self,
+ src_path: bytes | str,
+ ref_snapshot: DirectorySnapshot,
+ new_snapshot: DirectorySnapshot,
+ *,
+ is_directory: bool,
+ ) -> Generator[FileSystemEvent]:
+ """Compares information from two directory snapshots (one taken before
+ the rename operation and another taken right after) to determine the
+ destination path of the file system object renamed, and yields
+ the appropriate events to be queued.
+ """
+ try:
+ f_inode = ref_snapshot.inode(src_path)
+ except KeyError:
+ # Probably caught a temporary file/directory that was renamed
+ # and deleted. Fires a sequence of created and deleted events
+ # for the path.
+ if is_directory:
+ yield DirCreatedEvent(src_path)
+ yield DirDeletedEvent(src_path)
+ else:
+ yield FileCreatedEvent(src_path)
+ yield FileDeletedEvent(src_path)
+ # We don't process any further and bail out assuming
+ # the event represents deletion/creation instead of movement.
+ return
+
+ dest_path = new_snapshot.path(f_inode)
+ if dest_path is not None:
+ dest_path = absolute_path(dest_path)
+ if is_directory:
+ yield DirMovedEvent(src_path, dest_path)
+ else:
+ yield FileMovedEvent(src_path, dest_path)
+ yield self._parent_dir_modified(src_path)
+ yield self._parent_dir_modified(dest_path)
+ if is_directory and self.watch.is_recursive:
+ # TODO: Do we need to fire moved events for the items
+ # inside the directory tree? Does kqueue does this
+ # all by itself? Check this and then enable this code
+ # only if it doesn't already.
+ # A: It doesn't. So I've enabled this block.
+ yield from generate_sub_moved_events(src_path, dest_path)
+ else:
+ # If the new snapshot does not have an inode for the
+ # old path, we haven't found the new name. Therefore,
+ # we mark it as deleted and remove unregister the path.
+ if is_directory:
+ yield DirDeletedEvent(src_path)
+ else:
+ yield FileDeletedEvent(src_path)
+ yield self._parent_dir_modified(src_path)
+
+ def _read_events(self, timeout: float) -> list[select.kevent]:
+ """Reads events from a call to the blocking
+ :meth:`select.kqueue.control()` method.
+
+ :param timeout:
+ Blocking timeout for reading events.
+ :type timeout:
+ ``float`` (seconds)
+ """
+ return self._kq.control(self._descriptors.kevents, MAX_EVENTS, timeout)
+
+ def queue_events(self, timeout: float) -> None:
+ """Queues events by reading them from a call to the blocking
+ :meth:`select.kqueue.control()` method.
+
+ :param timeout:
+ Blocking timeout for reading events.
+ :type timeout:
+ ``float`` (seconds)
+ """
+ with self._lock:
+ try:
+ event_list = self._read_events(timeout)
+ # TODO: investigate why order appears to be reversed
+ event_list.reverse()
+
+ # Take a fresh snapshot of the directory and update the
+ # saved snapshot.
+ new_snapshot = DirectorySnapshot(self.watch.path, recursive=self.watch.is_recursive)
+ ref_snapshot = self._snapshot
+ self._snapshot = new_snapshot
+ diff_events = new_snapshot - ref_snapshot
+
+ # Process events
+ for directory_created in diff_events.dirs_created:
+ self.queue_event(DirCreatedEvent(directory_created))
+ for file_created in diff_events.files_created:
+ self.queue_event(FileCreatedEvent(file_created))
+ for file_modified in diff_events.files_modified:
+ self.queue_event(FileModifiedEvent(file_modified))
+
+ for kev in event_list:
+ for event in self._gen_kqueue_events(kev, ref_snapshot, new_snapshot):
+ self.queue_event(event)
+
+ except OSError as e:
+ if e.errno != errno.EBADF:
+ raise
+
+ def on_thread_stop(self) -> None:
+ # Clean up.
+ with self._lock:
+ self._descriptors.clear()
+ self._kq.close()
+
+
+class KqueueObserver(BaseObserver):
+ """Observer thread that schedules watching directories and dispatches
+ calls to event handlers.
+ """
+
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__(KqueueEmitter, timeout=timeout)
diff --git a/src/mcpstore/utils/watchdog/observers/polling.py b/src/mcpstore/utils/watchdog/observers/polling.py
new file mode 100644
index 00000000..d2a53ab6
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/polling.py
@@ -0,0 +1,143 @@
+""":module: watchdog.observers.polling
+:synopsis: Polling emitter implementation.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+Classes
+-------
+.. autoclass:: PollingObserver
+ :members:
+ :show-inheritance:
+
+.. autoclass:: PollingObserverVFS
+ :members:
+ :show-inheritance:
+ :special-members:
+"""
+
+from __future__ import annotations
+
+import os
+import threading
+from functools import partial
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+)
+from mcpstore.utils.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, \
+ EventEmitter
+from mcpstore.utils.watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff, EmptyDirectorySnapshot
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+ from typing import Callable
+
+ from mcpstore.utils.watchdog.events import FileSystemEvent
+ from mcpstore.utils.watchdog.observers.api import EventQueue, ObservedWatch
+
+
+class PollingEmitter(EventEmitter):
+ """Platform-independent emitter that polls a directory to detect file
+ system changes.
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ stat: Callable[[str], os.stat_result] = os.stat,
+ listdir: Callable[[str | None], Iterator[os.DirEntry]] = os.scandir,
+ ) -> None:
+ super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
+ self._snapshot: DirectorySnapshot = EmptyDirectorySnapshot()
+ self._lock = threading.Lock()
+ self._take_snapshot: Callable[[], DirectorySnapshot] = lambda: DirectorySnapshot(
+ self.watch.path,
+ recursive=self.watch.is_recursive,
+ stat=stat,
+ listdir=listdir,
+ )
+
+ def on_thread_start(self) -> None:
+ self._snapshot = self._take_snapshot()
+
+ def queue_events(self, timeout: float) -> None:
+ # We don't want to hit the disk continuously.
+ # timeout behaves like an interval for polling emitters.
+ if self.stopped_event.wait(timeout):
+ return
+
+ with self._lock:
+ if not self.should_keep_running():
+ return
+
+ # Get event diff between fresh snapshot and previous snapshot.
+ # Update snapshot.
+ try:
+ new_snapshot = self._take_snapshot()
+ except OSError:
+ self.queue_event(DirDeletedEvent(self.watch.path))
+ self.stop()
+ return
+
+ events = DirectorySnapshotDiff(self._snapshot, new_snapshot)
+ self._snapshot = new_snapshot
+
+ # Files.
+ for src_path in events.files_deleted:
+ self.queue_event(FileDeletedEvent(src_path))
+ for src_path in events.files_modified:
+ self.queue_event(FileModifiedEvent(src_path))
+ for src_path in events.files_created:
+ self.queue_event(FileCreatedEvent(src_path))
+ for src_path, dest_path in events.files_moved:
+ self.queue_event(FileMovedEvent(src_path, dest_path))
+
+ # Directories.
+ for src_path in events.dirs_deleted:
+ self.queue_event(DirDeletedEvent(src_path))
+ for src_path in events.dirs_modified:
+ self.queue_event(DirModifiedEvent(src_path))
+ for src_path in events.dirs_created:
+ self.queue_event(DirCreatedEvent(src_path))
+ for src_path, dest_path in events.dirs_moved:
+ self.queue_event(DirMovedEvent(src_path, dest_path))
+
+
+class PollingObserver(BaseObserver):
+ """Platform-independent observer that polls a directory to detect file
+ system changes.
+ """
+
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__(PollingEmitter, timeout=timeout)
+
+
+class PollingObserverVFS(BaseObserver):
+ """File system independent observer that polls a directory to detect changes."""
+
+ def __init__(
+ self,
+ stat: Callable[[str], os.stat_result],
+ listdir: Callable[[str | None], Iterator[os.DirEntry]],
+ *,
+ polling_interval: int = 1,
+ ) -> None:
+ """:param stat: stat function. See ``os.stat`` for details.
+ :param listdir: listdir function. See ``os.scandir`` for details.
+ :type polling_interval: int
+ :param polling_interval: interval in seconds between polling the file system.
+ """
+ emitter_cls = partial(PollingEmitter, stat=stat, listdir=listdir)
+ super().__init__(emitter_cls, timeout=polling_interval) # type: ignore[arg-type]
diff --git a/src/mcpstore/utils/watchdog/observers/read_directory_changes.py b/src/mcpstore/utils/watchdog/observers/read_directory_changes.py
new file mode 100644
index 00000000..1a5f81b4
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/read_directory_changes.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import os.path
+import platform
+import threading
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ generate_sub_created_events,
+ generate_sub_moved_events,
+)
+from mcpstore.utils.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, \
+ EventEmitter
+from mcpstore.utils.watchdog.observers.winapi import close_directory_handle, get_directory_handle, read_events
+
+if TYPE_CHECKING:
+ from ctypes.wintypes import HANDLE
+
+ from mcpstore.utils.watchdog.events import FileSystemEvent
+ from mcpstore.utils.watchdog.observers.api import EventQueue, ObservedWatch
+ from mcpstore.utils.watchdog.observers.winapi import WinAPINativeEvent
+
+
+class WindowsApiEmitter(EventEmitter):
+ """Windows API-based emitter that uses ReadDirectoryChangesW
+ to detect file system changes for a watch.
+ """
+
+ def __init__(
+ self,
+ event_queue: EventQueue,
+ watch: ObservedWatch,
+ *,
+ timeout: float = DEFAULT_EMITTER_TIMEOUT,
+ event_filter: list[type[FileSystemEvent]] | None = None,
+ ) -> None:
+ super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
+ self._lock = threading.Lock()
+ self._whandle: HANDLE | None = None
+
+ def on_thread_start(self) -> None:
+ self._whandle = get_directory_handle(self.watch.path)
+
+ if platform.python_implementation() == "PyPy":
+
+ def start(self) -> None:
+ """PyPy needs some time before receiving events, see #792."""
+ from time import sleep
+
+ super().start()
+ sleep(0.01)
+
+ def on_thread_stop(self) -> None:
+ if self._whandle:
+ close_directory_handle(self._whandle)
+
+ def _read_events(self) -> list[WinAPINativeEvent]:
+ if not self._whandle:
+ return []
+ return read_events(self._whandle, self.watch.path, recursive=self.watch.is_recursive)
+
+ def queue_events(self, timeout: float) -> None:
+ winapi_events = self._read_events()
+ with self._lock:
+ last_renamed_src_path = ""
+ for winapi_event in winapi_events:
+ src_path = os.path.join(self.watch.path, winapi_event.src_path)
+
+ if winapi_event.is_renamed_old:
+ last_renamed_src_path = src_path
+ elif winapi_event.is_renamed_new:
+ dest_path = src_path
+ src_path = last_renamed_src_path
+ if os.path.isdir(dest_path):
+ self.queue_event(DirMovedEvent(src_path, dest_path))
+ if self.watch.is_recursive:
+ for sub_moved_event in generate_sub_moved_events(src_path, dest_path):
+ self.queue_event(sub_moved_event)
+ else:
+ self.queue_event(FileMovedEvent(src_path, dest_path))
+ elif winapi_event.is_modified:
+ self.queue_event((DirModifiedEvent if os.path.isdir(src_path) else FileModifiedEvent)(src_path))
+ elif winapi_event.is_added:
+ isdir = os.path.isdir(src_path)
+ self.queue_event((DirCreatedEvent if isdir else FileCreatedEvent)(src_path))
+ if isdir and self.watch.is_recursive:
+ for sub_created_event in generate_sub_created_events(src_path):
+ self.queue_event(sub_created_event)
+ elif winapi_event.is_removed:
+ self.queue_event(FileDeletedEvent(src_path))
+ elif winapi_event.is_removed_self:
+ self.queue_event(DirDeletedEvent(self.watch.path))
+ self.stop()
+
+
+class WindowsApiObserver(BaseObserver):
+ """Observer thread that schedules watching directories and dispatches
+ calls to event handlers.
+ """
+
+ def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
+ super().__init__(WindowsApiEmitter, timeout=timeout)
diff --git a/src/mcpstore/utils/watchdog/observers/winapi.py b/src/mcpstore/utils/watchdog/observers/winapi.py
new file mode 100644
index 00000000..3eb0493a
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/observers/winapi.py
@@ -0,0 +1,382 @@
+""":module: watchdog.observers.winapi
+:synopsis: Windows API-Python interface (removes dependency on ``pywin32``).
+:author: theller@ctypes.org (Thomas Heller)
+:author: will@willmcgugan.com (Will McGugan)
+:author: ryan@rfk.id.au (Ryan Kelly)
+:author: yesudeep@gmail.com (Yesudeep Mangalapilly)
+:author: thomas.amland@gmail.com (Thomas Amland)
+:author: Mickaël Schoentgen
+:platforms: windows
+"""
+
+from __future__ import annotations
+
+import contextlib
+import ctypes
+from ctypes.wintypes import BOOL, DWORD, HANDLE, LPCWSTR, LPVOID, LPWSTR
+from dataclasses import dataclass
+from functools import reduce
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import Any
+
+# Invalid handle value.
+INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
+
+# File notification constants.
+FILE_NOTIFY_CHANGE_FILE_NAME = 0x01
+FILE_NOTIFY_CHANGE_DIR_NAME = 0x02
+FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x04
+FILE_NOTIFY_CHANGE_SIZE = 0x08
+FILE_NOTIFY_CHANGE_LAST_WRITE = 0x010
+FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x020
+FILE_NOTIFY_CHANGE_CREATION = 0x040
+FILE_NOTIFY_CHANGE_SECURITY = 0x0100
+
+FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
+FILE_FLAG_OVERLAPPED = 0x40000000
+FILE_LIST_DIRECTORY = 1
+FILE_SHARE_READ = 0x01
+FILE_SHARE_WRITE = 0x02
+FILE_SHARE_DELETE = 0x04
+OPEN_EXISTING = 3
+
+VOLUME_NAME_NT = 0x02
+
+# File action constants.
+FILE_ACTION_CREATED = 1
+FILE_ACTION_DELETED = 2
+FILE_ACTION_MODIFIED = 3
+FILE_ACTION_RENAMED_OLD_NAME = 4
+FILE_ACTION_RENAMED_NEW_NAME = 5
+FILE_ACTION_DELETED_SELF = 0xFFFE
+FILE_ACTION_OVERFLOW = 0xFFFF
+
+# Aliases
+FILE_ACTION_ADDED = FILE_ACTION_CREATED
+FILE_ACTION_REMOVED = FILE_ACTION_DELETED
+FILE_ACTION_REMOVED_SELF = FILE_ACTION_DELETED_SELF
+
+THREAD_TERMINATE = 0x0001
+
+# IO waiting constants.
+WAIT_ABANDONED = 0x00000080
+WAIT_IO_COMPLETION = 0x000000C0
+WAIT_OBJECT_0 = 0x00000000
+WAIT_TIMEOUT = 0x00000102
+
+# Error codes
+ERROR_OPERATION_ABORTED = 995
+
+
+class OVERLAPPED(ctypes.Structure):
+ _fields_ = (
+ ("Internal", LPVOID),
+ ("InternalHigh", LPVOID),
+ ("Offset", DWORD),
+ ("OffsetHigh", DWORD),
+ ("Pointer", LPVOID),
+ ("hEvent", HANDLE),
+ )
+
+
+def _errcheck_bool(value: Any | None, func: Any, args: Any) -> Any:
+ if not value:
+ raise ctypes.WinError() # type: ignore[attr-defined]
+ return args
+
+
+def _errcheck_handle(value: Any | None, func: Any, args: Any) -> Any:
+ if not value:
+ raise ctypes.WinError() # type: ignore[attr-defined]
+ if value == INVALID_HANDLE_VALUE:
+ raise ctypes.WinError() # type: ignore[attr-defined]
+ return args
+
+
+def _errcheck_dword(value: Any | None, func: Any, args: Any) -> Any:
+ if value == 0xFFFFFFFF:
+ raise ctypes.WinError() # type: ignore[attr-defined]
+ return args
+
+
+kernel32 = ctypes.WinDLL("kernel32") # type: ignore[attr-defined]
+
+ReadDirectoryChangesW = kernel32.ReadDirectoryChangesW
+ReadDirectoryChangesW.restype = BOOL
+ReadDirectoryChangesW.errcheck = _errcheck_bool
+ReadDirectoryChangesW.argtypes = (
+ HANDLE, # hDirectory
+ LPVOID, # lpBuffer
+ DWORD, # nBufferLength
+ BOOL, # bWatchSubtree
+ DWORD, # dwNotifyFilter
+ ctypes.POINTER(DWORD), # lpBytesReturned
+ ctypes.POINTER(OVERLAPPED), # lpOverlapped
+ LPVOID, # FileIOCompletionRoutine # lpCompletionRoutine
+)
+
+CreateFileW = kernel32.CreateFileW
+CreateFileW.restype = HANDLE
+CreateFileW.errcheck = _errcheck_handle
+CreateFileW.argtypes = (
+ LPCWSTR, # lpFileName
+ DWORD, # dwDesiredAccess
+ DWORD, # dwShareMode
+ LPVOID, # lpSecurityAttributes
+ DWORD, # dwCreationDisposition
+ DWORD, # dwFlagsAndAttributes
+ HANDLE, # hTemplateFile
+)
+
+CloseHandle = kernel32.CloseHandle
+CloseHandle.restype = BOOL
+CloseHandle.argtypes = (HANDLE,) # hObject
+
+CancelIoEx = kernel32.CancelIoEx
+CancelIoEx.restype = BOOL
+CancelIoEx.errcheck = _errcheck_bool
+CancelIoEx.argtypes = (
+ HANDLE, # hObject
+ ctypes.POINTER(OVERLAPPED), # lpOverlapped
+)
+
+CreateEvent = kernel32.CreateEventW
+CreateEvent.restype = HANDLE
+CreateEvent.errcheck = _errcheck_handle
+CreateEvent.argtypes = (
+ LPVOID, # lpEventAttributes
+ BOOL, # bManualReset
+ BOOL, # bInitialState
+ LPCWSTR, # lpName
+)
+
+SetEvent = kernel32.SetEvent
+SetEvent.restype = BOOL
+SetEvent.errcheck = _errcheck_bool
+SetEvent.argtypes = (HANDLE,) # hEvent
+
+WaitForSingleObjectEx = kernel32.WaitForSingleObjectEx
+WaitForSingleObjectEx.restype = DWORD
+WaitForSingleObjectEx.errcheck = _errcheck_dword
+WaitForSingleObjectEx.argtypes = (
+ HANDLE, # hObject
+ DWORD, # dwMilliseconds
+ BOOL, # bAlertable
+)
+
+CreateIoCompletionPort = kernel32.CreateIoCompletionPort
+CreateIoCompletionPort.restype = HANDLE
+CreateIoCompletionPort.errcheck = _errcheck_handle
+CreateIoCompletionPort.argtypes = (
+ HANDLE, # FileHandle
+ HANDLE, # ExistingCompletionPort
+ LPVOID, # CompletionKey
+ DWORD, # NumberOfConcurrentThreads
+)
+
+GetQueuedCompletionStatus = kernel32.GetQueuedCompletionStatus
+GetQueuedCompletionStatus.restype = BOOL
+GetQueuedCompletionStatus.errcheck = _errcheck_bool
+GetQueuedCompletionStatus.argtypes = (
+ HANDLE, # CompletionPort
+ LPVOID, # lpNumberOfBytesTransferred
+ LPVOID, # lpCompletionKey
+ ctypes.POINTER(OVERLAPPED), # lpOverlapped
+ DWORD, # dwMilliseconds
+)
+
+PostQueuedCompletionStatus = kernel32.PostQueuedCompletionStatus
+PostQueuedCompletionStatus.restype = BOOL
+PostQueuedCompletionStatus.errcheck = _errcheck_bool
+PostQueuedCompletionStatus.argtypes = (
+ HANDLE, # CompletionPort
+ DWORD, # lpNumberOfBytesTransferred
+ DWORD, # lpCompletionKey
+ ctypes.POINTER(OVERLAPPED), # lpOverlapped
+)
+
+
+GetFinalPathNameByHandleW = kernel32.GetFinalPathNameByHandleW
+GetFinalPathNameByHandleW.restype = DWORD
+GetFinalPathNameByHandleW.errcheck = _errcheck_dword
+GetFinalPathNameByHandleW.argtypes = (
+ HANDLE, # hFile
+ LPWSTR, # lpszFilePath
+ DWORD, # cchFilePath
+ DWORD, # DWORD
+)
+
+
+class FileNotifyInformation(ctypes.Structure):
+ _fields_ = (
+ ("NextEntryOffset", DWORD),
+ ("Action", DWORD),
+ ("FileNameLength", DWORD),
+ ("FileName", (ctypes.c_char * 1)),
+ )
+
+
+LPFNI = ctypes.POINTER(FileNotifyInformation)
+
+
+# We don't need to recalculate these flags every time a call is made to
+# the win32 API functions.
+WATCHDOG_FILE_FLAGS = FILE_FLAG_BACKUP_SEMANTICS
+WATCHDOG_FILE_SHARE_FLAGS = reduce(
+ lambda x, y: x | y,
+ [
+ FILE_SHARE_READ,
+ FILE_SHARE_WRITE,
+ FILE_SHARE_DELETE,
+ ],
+)
+WATCHDOG_FILE_NOTIFY_FLAGS = reduce(
+ lambda x, y: x | y,
+ [
+ FILE_NOTIFY_CHANGE_FILE_NAME,
+ FILE_NOTIFY_CHANGE_DIR_NAME,
+ FILE_NOTIFY_CHANGE_ATTRIBUTES,
+ FILE_NOTIFY_CHANGE_SIZE,
+ FILE_NOTIFY_CHANGE_LAST_WRITE,
+ FILE_NOTIFY_CHANGE_SECURITY,
+ FILE_NOTIFY_CHANGE_LAST_ACCESS,
+ FILE_NOTIFY_CHANGE_CREATION,
+ ],
+)
+
+# ReadDirectoryChangesW buffer length.
+# To handle cases with lot of changes, this seems the highest safest value we can use.
+# Note: it will fail with ERROR_INVALID_PARAMETER when it is greater than 64 KB and
+# the application is monitoring a directory over the network.
+# This is due to a packet size limitation with the underlying file sharing protocols.
+# https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw#remarks
+BUFFER_SIZE = 64000
+
+# Buffer length for path-related stuff.
+# Introduced to keep the old behavior when we bumped BUFFER_SIZE from 2048 to 64000 in v1.0.0.
+PATH_BUFFER_SIZE = 2048
+
+
+def _parse_event_buffer(read_buffer: bytes, n_bytes: int) -> list[tuple[int, str]]:
+ results = []
+ while n_bytes > 0:
+ fni = ctypes.cast(read_buffer, LPFNI)[0] # type: ignore[arg-type]
+ ptr = ctypes.addressof(fni) + FileNotifyInformation.FileName.offset
+ filename = ctypes.string_at(ptr, fni.FileNameLength)
+ results.append((fni.Action, filename.decode("utf-16")))
+ num_to_skip = fni.NextEntryOffset
+ if num_to_skip <= 0:
+ break
+ read_buffer = read_buffer[num_to_skip:]
+ n_bytes -= num_to_skip # num_to_skip is long. n_bytes should be long too.
+ return results
+
+
+def _is_observed_path_deleted(handle: HANDLE, path: str) -> bool:
+ # Comparison of observed path and actual path, returned by
+ # GetFinalPathNameByHandleW. If directory moved to the trash bin, or
+ # deleted, actual path will not be equal to observed path.
+ buff = ctypes.create_unicode_buffer(PATH_BUFFER_SIZE)
+ GetFinalPathNameByHandleW(handle, buff, PATH_BUFFER_SIZE, VOLUME_NAME_NT)
+ return buff.value != path
+
+
+def _generate_observed_path_deleted_event() -> tuple[bytes, int]:
+ # Create synthetic event for notify that observed directory is deleted
+ path = ctypes.create_unicode_buffer(".")
+ event = FileNotifyInformation(0, FILE_ACTION_DELETED_SELF, len(path), path.value.encode("utf-8"))
+ event_size = ctypes.sizeof(event)
+ buff = ctypes.create_string_buffer(PATH_BUFFER_SIZE)
+ ctypes.memmove(buff, ctypes.addressof(event), event_size)
+ return buff.raw, event_size
+
+
+def get_directory_handle(path: str) -> HANDLE:
+ """Returns a Windows handle to the specified directory path."""
+ return CreateFileW(
+ path,
+ FILE_LIST_DIRECTORY,
+ WATCHDOG_FILE_SHARE_FLAGS,
+ None,
+ OPEN_EXISTING,
+ WATCHDOG_FILE_FLAGS,
+ None,
+ )
+
+
+def close_directory_handle(handle: HANDLE) -> None:
+ try:
+ CancelIoEx(handle, None) # force ReadDirectoryChangesW to return
+ CloseHandle(handle)
+ except OSError:
+ with contextlib.suppress(Exception):
+ CloseHandle(handle)
+
+
+def read_directory_changes(handle: HANDLE, path: str, *, recursive: bool) -> tuple[bytes, int]:
+ """Read changes to the directory using the specified directory handle.
+
+ https://timgolden.me.uk/pywin32-docs/win32file__ReadDirectoryChangesW_meth.html
+ """
+ event_buffer = ctypes.create_string_buffer(BUFFER_SIZE)
+ nbytes = DWORD()
+ try:
+ ReadDirectoryChangesW(
+ handle,
+ ctypes.byref(event_buffer),
+ len(event_buffer),
+ recursive,
+ WATCHDOG_FILE_NOTIFY_FLAGS,
+ ctypes.byref(nbytes),
+ None,
+ None,
+ )
+ except OSError as e:
+ if e.winerror == ERROR_OPERATION_ABORTED: # type: ignore[attr-defined]
+ return event_buffer.raw, 0
+
+ # Handle the case when the root path is deleted
+ if _is_observed_path_deleted(handle, path):
+ return _generate_observed_path_deleted_event()
+
+ raise
+
+ return event_buffer.raw, int(nbytes.value)
+
+
+@dataclass(unsafe_hash=True)
+class WinAPINativeEvent:
+ action: int
+ src_path: str
+
+ @property
+ def is_added(self) -> bool:
+ return self.action == FILE_ACTION_ADDED
+
+ @property
+ def is_removed(self) -> bool:
+ return self.action == FILE_ACTION_REMOVED
+
+ @property
+ def is_modified(self) -> bool:
+ return self.action == FILE_ACTION_MODIFIED
+
+ @property
+ def is_renamed_old(self) -> bool:
+ return self.action == FILE_ACTION_RENAMED_OLD_NAME
+
+ @property
+ def is_renamed_new(self) -> bool:
+ return self.action == FILE_ACTION_RENAMED_NEW_NAME
+
+ @property
+ def is_removed_self(self) -> bool:
+ return self.action == FILE_ACTION_REMOVED_SELF
+
+
+def read_events(handle: HANDLE, path: str, *, recursive: bool) -> list[WinAPINativeEvent]:
+ buf, nbytes = read_directory_changes(handle, path, recursive=recursive)
+ events = _parse_event_buffer(buf, nbytes)
+ return [WinAPINativeEvent(action, src_path) for action, src_path in events]
diff --git a/src/mcpstore/utils/watchdog/py.typed b/src/mcpstore/utils/watchdog/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/src/mcpstore/utils/watchdog/tricks/__init__.py b/src/mcpstore/utils/watchdog/tricks/__init__.py
new file mode 100644
index 00000000..cf0a0be3
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/tricks/__init__.py
@@ -0,0 +1,294 @@
+""":module: watchdog.tricks
+:synopsis: Utility event handlers.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+Classes
+-------
+.. autoclass:: Trick
+ :members:
+ :show-inheritance:
+
+.. autoclass:: LoggerTrick
+ :members:
+ :show-inheritance:
+
+.. autoclass:: ShellCommandTrick
+ :members:
+ :show-inheritance:
+
+.. autoclass:: AutoRestartTrick
+ :members:
+ :show-inheritance:
+
+"""
+
+from __future__ import annotations
+
+import contextlib
+import functools
+import logging
+import os
+import signal
+import subprocess
+import threading
+import time
+
+from mcpstore.utils.watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE, EVENT_TYPE_OPENED, FileSystemEvent, \
+ PatternMatchingEventHandler
+from mcpstore.utils.watchdog.utils import echo, platform
+from mcpstore.utils.watchdog.utils.event_debouncer import EventDebouncer
+from mcpstore.utils.watchdog.utils.process_watcher import ProcessWatcher
+
+logger = logging.getLogger(__name__)
+echo_events = functools.partial(echo.echo, write=lambda msg: logger.info(msg))
+
+
+class Trick(PatternMatchingEventHandler):
+ """Your tricks should subclass this class."""
+
+ def __repr__(self) -> str:
+ return f"<{type(self).__name__}>"
+
+ @classmethod
+ def generate_yaml(cls) -> str:
+ return f"""- {cls.__module__}.{cls.__name__}:
+ args:
+ - argument1
+ - argument2
+ kwargs:
+ patterns:
+ - "*.py"
+ - "*.js"
+ ignore_patterns:
+ - "version.py"
+ ignore_directories: false
+"""
+
+
+class LoggerTrick(Trick):
+ """A simple trick that does only logs events."""
+
+ @echo_events
+ def on_any_event(self, event: FileSystemEvent) -> None:
+ pass
+
+
+class ShellCommandTrick(Trick):
+ """Executes shell commands in response to matched events."""
+
+ def __init__(
+ self,
+ shell_command: str,
+ *,
+ patterns: list[str] | None = None,
+ ignore_patterns: list[str] | None = None,
+ ignore_directories: bool = False,
+ wait_for_process: bool = False,
+ drop_during_process: bool = False,
+ ):
+ super().__init__(
+ patterns=patterns,
+ ignore_patterns=ignore_patterns,
+ ignore_directories=ignore_directories,
+ )
+ self.shell_command = shell_command
+ self.wait_for_process = wait_for_process
+ self.drop_during_process = drop_during_process
+
+ self.process: subprocess.Popen[bytes] | None = None
+ self._process_watchers: set[ProcessWatcher] = set()
+
+ def on_any_event(self, event: FileSystemEvent) -> None:
+ if event.event_type in {EVENT_TYPE_OPENED, EVENT_TYPE_CLOSED_NO_WRITE}:
+ # FIXME: see issue #949, and find a way to better handle that scenario
+ return
+
+ from string import Template
+
+ if self.drop_during_process and self.is_process_running():
+ return
+
+ object_type = "directory" if event.is_directory else "file"
+ context = {
+ "watch_src_path": event.src_path,
+ "watch_dest_path": "",
+ "watch_event_type": event.event_type,
+ "watch_object": object_type,
+ }
+
+ if self.shell_command is None:
+ if hasattr(event, "dest_path"):
+ context["dest_path"] = event.dest_path
+ command = 'echo "${watch_event_type} ${watch_object} from ${watch_src_path} to ${watch_dest_path}"'
+ else:
+ command = 'echo "${watch_event_type} ${watch_object} ${watch_src_path}"'
+ else:
+ if hasattr(event, "dest_path"):
+ context["watch_dest_path"] = event.dest_path
+ command = self.shell_command
+
+ command = Template(command).safe_substitute(**context)
+ self.process = subprocess.Popen(command, shell=True)
+ if self.wait_for_process:
+ self.process.wait()
+ else:
+ process_watcher = ProcessWatcher(self.process, None)
+ self._process_watchers.add(process_watcher)
+ process_watcher.process_termination_callback = functools.partial(
+ self._process_watchers.discard,
+ process_watcher,
+ )
+ process_watcher.start()
+
+ def is_process_running(self) -> bool:
+ return bool(self._process_watchers or (self.process is not None and self.process.poll() is None))
+
+
+class AutoRestartTrick(Trick):
+ """Starts a long-running subprocess and restarts it on matched events.
+
+ The command parameter is a list of command arguments, such as
+ `['bin/myserver', '-c', 'etc/myconfig.ini']`.
+
+ Call `start()` after creating the Trick. Call `stop()` when stopping
+ the process.
+ """
+
+ def __init__(
+ self,
+ command: list[str],
+ *,
+ patterns: list[str] | None = None,
+ ignore_patterns: list[str] | None = None,
+ ignore_directories: bool = False,
+ stop_signal: signal.Signals | int = signal.SIGINT,
+ kill_after: int = 10,
+ debounce_interval_seconds: int = 0,
+ restart_on_command_exit: bool = True,
+ ):
+ if kill_after < 0:
+ error = "kill_after must be non-negative."
+ raise ValueError(error)
+ if debounce_interval_seconds < 0:
+ error = "debounce_interval_seconds must be non-negative."
+ raise ValueError(error)
+
+ super().__init__(
+ patterns=patterns,
+ ignore_patterns=ignore_patterns,
+ ignore_directories=ignore_directories,
+ )
+
+ self.command = command
+ self.stop_signal = stop_signal.value if isinstance(stop_signal, signal.Signals) else stop_signal
+ self.kill_after = kill_after
+ self.debounce_interval_seconds = debounce_interval_seconds
+ self.restart_on_command_exit = restart_on_command_exit
+
+ self.process: subprocess.Popen[bytes] | None = None
+ self.process_watcher: ProcessWatcher | None = None
+ self.event_debouncer: EventDebouncer | None = None
+ self.restart_count = 0
+
+ self._is_process_stopping = False
+ self._is_trick_stopping = False
+ self._stopping_lock = threading.RLock()
+
+ def start(self) -> None:
+ if self.debounce_interval_seconds:
+ self.event_debouncer = EventDebouncer(
+ debounce_interval_seconds=self.debounce_interval_seconds,
+ events_callback=lambda events: self._restart_process(),
+ )
+ self.event_debouncer.start()
+ self._start_process()
+
+ def stop(self) -> None:
+ # Ensure the body of the function is only run once.
+ with self._stopping_lock:
+ if self._is_trick_stopping:
+ return
+ self._is_trick_stopping = True
+
+ process_watcher = self.process_watcher
+ if self.event_debouncer is not None:
+ self.event_debouncer.stop()
+ self._stop_process()
+
+ # Don't leak threads: Wait for background threads to stop.
+ if self.event_debouncer is not None:
+ self.event_debouncer.join()
+ if process_watcher is not None:
+ process_watcher.join()
+
+ def _start_process(self) -> None:
+ if self._is_trick_stopping:
+ return
+
+ # windows doesn't have setsid
+ self.process = subprocess.Popen(self.command, preexec_fn=getattr(os, "setsid", None))
+ if self.restart_on_command_exit:
+ self.process_watcher = ProcessWatcher(self.process, self._restart_process)
+ self.process_watcher.start()
+
+ def _stop_process(self) -> None:
+ # Ensure the body of the function is not run in parallel in different threads.
+ with self._stopping_lock:
+ if self._is_process_stopping:
+ return
+ self._is_process_stopping = True
+
+ try:
+ if self.process_watcher is not None:
+ self.process_watcher.stop()
+ self.process_watcher = None
+
+ if self.process is not None:
+ try:
+ kill_process(self.process.pid, self.stop_signal)
+ except OSError:
+ # Process is already gone
+ pass
+ else:
+ kill_time = time.time() + self.kill_after
+ while time.time() < kill_time:
+ if self.process.poll() is not None:
+ break
+ time.sleep(0.25)
+ else:
+ # Process is already gone
+ with contextlib.suppress(OSError):
+ kill_process(self.process.pid, 9)
+ self.process = None
+ finally:
+ self._is_process_stopping = False
+
+ @echo_events
+ def on_any_event(self, event: FileSystemEvent) -> None:
+ if event.event_type in {EVENT_TYPE_OPENED, EVENT_TYPE_CLOSED_NO_WRITE}:
+ # FIXME: see issue #949, and find a way to better handle that scenario
+ return
+
+ if self.event_debouncer is not None:
+ self.event_debouncer.handle_event(event)
+ else:
+ self._restart_process()
+
+ def _restart_process(self) -> None:
+ if self._is_trick_stopping:
+ return
+ self._stop_process()
+ self._start_process()
+ self.restart_count += 1
+
+
+if platform.is_windows():
+
+ def kill_process(pid: int, stop_signal: int) -> None:
+ os.kill(pid, stop_signal)
+
+else:
+
+ def kill_process(pid: int, stop_signal: int) -> None:
+ os.killpg(os.getpgid(pid), stop_signal)
diff --git a/src/mcpstore/utils/watchdog/utils/__init__.py b/src/mcpstore/utils/watchdog/utils/__init__.py
new file mode 100644
index 00000000..957e241b
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/__init__.py
@@ -0,0 +1,122 @@
+""":module: watchdog.utils
+:synopsis: Utility classes and functions.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+Classes
+-------
+.. autoclass:: BaseThread
+ :members:
+ :show-inheritance:
+ :inherited-members:
+
+"""
+
+from __future__ import annotations
+
+import sys
+import threading
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from types import ModuleType
+
+ from mcpstore.utils.watchdog.tricks import Trick
+
+
+class UnsupportedLibcError(Exception):
+ pass
+
+
+class WatchdogShutdownError(Exception):
+ """Semantic exception used to signal an external shutdown event."""
+
+
+class BaseThread(threading.Thread):
+ """Convenience class for creating stoppable threads."""
+
+ def __init__(self) -> None:
+ threading.Thread.__init__(self)
+ if hasattr(self, "daemon"):
+ self.daemon = True
+ else:
+ self.setDaemon(True)
+ self._stopped_event = threading.Event()
+
+ @property
+ def stopped_event(self) -> threading.Event:
+ return self._stopped_event
+
+ def should_keep_running(self) -> bool:
+ """Determines whether the thread should continue running."""
+ return not self._stopped_event.is_set()
+
+ def on_thread_stop(self) -> None:
+ """Override this method instead of :meth:`stop()`.
+ :meth:`stop()` calls this method.
+
+ This method is called immediately after the thread is signaled to stop.
+ """
+
+ def stop(self) -> None:
+ """Signals the thread to stop."""
+ self._stopped_event.set()
+ self.on_thread_stop()
+
+ def on_thread_start(self) -> None:
+ """Override this method instead of :meth:`start()`. :meth:`start()`
+ calls this method.
+
+ This method is called right before this thread is started and this
+ object's run() method is invoked.
+ """
+
+ def start(self) -> None:
+ self.on_thread_start()
+ threading.Thread.start(self)
+
+
+def load_module(module_name: str) -> ModuleType:
+ """Imports a module given its name and returns a handle to it."""
+ try:
+ __import__(module_name)
+ except ImportError as e:
+ error = f"No module named {module_name}"
+ raise ImportError(error) from e
+ return sys.modules[module_name]
+
+
+def load_class(dotted_path: str) -> type[Trick]:
+ """Loads and returns a class definition provided a dotted path
+ specification the last part of the dotted path is the class name
+ and there is at least one module name preceding the class name.
+
+ Notes
+ -----
+ You will need to ensure that the module you are trying to load
+ exists in the Python path.
+
+ Examples
+ --------
+ - module.name.ClassName # Provided module.name is in the Python path.
+ - module.ClassName # Provided module is in the Python path.
+
+ What won't work:
+ - ClassName
+ - modle.name.ClassName # Typo in module name.
+ - module.name.ClasNam # Typo in classname.
+
+ """
+ dotted_path_split = dotted_path.split(".")
+ if len(dotted_path_split) <= 1:
+ error = f"Dotted module path {dotted_path} must contain a module name and a classname"
+ raise ValueError(error)
+ klass_name = dotted_path_split[-1]
+ module_name = ".".join(dotted_path_split[:-1])
+
+ module = load_module(module_name)
+ if hasattr(module, klass_name):
+ return getattr(module, klass_name)
+
+ error = f"Module {module_name} does not have class attribute {klass_name}"
+ raise AttributeError(error)
diff --git a/src/mcpstore/utils/watchdog/utils/backwards_compat.py b/src/mcpstore/utils/watchdog/utils/backwards_compat.py
new file mode 100644
index 00000000..4c023ab9
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/backwards_compat.py
@@ -0,0 +1,148 @@
+# ruff: noqa
+# fmt: off
+# type: ignore
+"""
+This file includes unmodified functions copied from Python 3.13's standard library
+for use on older Python versions.
+
+Functions copied:
+- glob.translate (from Lib/glob.py)
+- fnmatch._translate (from Lib/fnmatch.py)
+
+Source: https://github.com/python/cpython
+License: Python Software Foundation License Version 2
+Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved
+
+Please delete me if/when this project releases forcing python >= 3.13
+"""
+
+import os
+import re
+
+
+# Copied from python 3.13 fnmatch._translate
+def _translate(pat, STAR, QUESTION_MARK):
+ res = []
+ add = res.append
+ i, n = 0, len(pat)
+ while i < n:
+ c = pat[i]
+ i = i+1
+ if c == '*':
+ # compress consecutive `*` into one
+ if (not res) or res[-1] is not STAR:
+ add(STAR)
+ elif c == '?':
+ add(QUESTION_MARK)
+ elif c == '[':
+ j = i
+ if j < n and pat[j] == '!':
+ j = j+1
+ if j < n and pat[j] == ']':
+ j = j+1
+ while j < n and pat[j] != ']':
+ j = j+1
+ if j >= n:
+ add('\\[')
+ else:
+ stuff = pat[i:j]
+ if '-' not in stuff:
+ stuff = stuff.replace('\\', r'\\')
+ else:
+ chunks = []
+ k = i+2 if pat[i] == '!' else i+1
+ while True:
+ k = pat.find('-', k, j)
+ if k < 0:
+ break
+ chunks.append(pat[i:k])
+ i = k+1
+ k = k+3
+ chunk = pat[i:j]
+ if chunk:
+ chunks.append(chunk)
+ else:
+ chunks[-1] += '-'
+ # Remove empty ranges -- invalid in RE.
+ for k in range(len(chunks)-1, 0, -1):
+ if chunks[k-1][-1] > chunks[k][0]:
+ chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
+ del chunks[k]
+ # Escape backslashes and hyphens for set difference (--).
+ # Hyphens that create ranges shouldn't be escaped.
+ stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
+ for s in chunks)
+ # Escape set operations (&&, ~~ and ||).
+ stuff = re.sub(r'([&~|])', r'\\\1', stuff)
+ i = j+1
+ if not stuff:
+ # Empty range: never match.
+ add('(?!)')
+ elif stuff == '!':
+ # Negated empty range: match any character.
+ add('.')
+ else:
+ if stuff[0] == '!':
+ stuff = '^' + stuff[1:]
+ elif stuff[0] in ('^', '['):
+ stuff = '\\' + stuff
+ add(f'[{stuff}]')
+ else:
+ add(re.escape(c))
+ assert i == n
+ return res
+
+
+def translate(pat, *, recursive=False, include_hidden=False, seps=None):
+ """Translate a pathname with shell wildcards to a regular expression.
+
+ If `recursive` is true, the pattern segment '**' will match any number of
+ path segments.
+
+ If `include_hidden` is true, wildcards can match path segments beginning
+ with a dot ('.').
+
+ If a sequence of separator characters is given to `seps`, they will be
+ used to split the pattern into segments and match path separators. If not
+ given, os.path.sep and os.path.altsep (where available) are used.
+ """
+ if not seps:
+ if os.path.altsep:
+ seps = (os.path.sep, os.path.altsep)
+ else:
+ seps = os.path.sep
+ escaped_seps = ''.join(map(re.escape, seps))
+ any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps
+ not_sep = f'[^{escaped_seps}]'
+ if include_hidden:
+ one_last_segment = f'{not_sep}+'
+ one_segment = f'{one_last_segment}{any_sep}'
+ any_segments = f'(?:.+{any_sep})?'
+ any_last_segments = '.*'
+ else:
+ one_last_segment = f'[^{escaped_seps}.]{not_sep}*'
+ one_segment = f'{one_last_segment}{any_sep}'
+ any_segments = f'(?:{one_segment})*'
+ any_last_segments = f'{any_segments}(?:{one_last_segment})?'
+
+ results = []
+ parts = re.split(any_sep, pat)
+ last_part_idx = len(parts) - 1
+ for idx, part in enumerate(parts):
+ if part == '*':
+ results.append(one_segment if idx < last_part_idx else one_last_segment)
+ elif recursive and part == '**':
+ if idx < last_part_idx:
+ if parts[idx + 1] != '**':
+ results.append(any_segments)
+ else:
+ results.append(any_last_segments)
+ else:
+ if part:
+ if not include_hidden and part[0] in '*?':
+ results.append(r'(?!\.)')
+ results.extend(_translate(part, f'{not_sep}*', not_sep))
+ if idx < last_part_idx:
+ results.append(any_sep)
+ res = ''.join(results)
+ return fr'(?s:{res})\Z'
diff --git a/src/mcpstore/utils/watchdog/utils/bricks.py b/src/mcpstore/utils/watchdog/utils/bricks.py
new file mode 100644
index 00000000..7dd311af
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/bricks.py
@@ -0,0 +1,90 @@
+"""Utility collections or "bricks".
+
+:module: watchdog.utils.bricks
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: lalinsky@gmail.com (Lukáš Lalinský)
+:author: python@rcn.com (Raymond Hettinger)
+:author: Mickaël Schoentgen
+
+Classes
+=======
+.. autoclass:: OrderedSetQueue
+ :members:
+ :show-inheritance:
+ :inherited-members:
+
+.. autoclass:: OrderedSet
+
+"""
+
+from __future__ import annotations
+
+import queue
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import Any
+
+
+class SkipRepeatsQueue(queue.Queue):
+ """Thread-safe implementation of an special queue where a
+ put of the last-item put'd will be dropped.
+
+ The implementation leverages locking already implemented in the base class
+ redefining only the primitives.
+
+ Queued items must be immutable and hashable so that they can be used
+ as dictionary keys. You must implement **only read-only properties** and
+ the :meth:`Item.__hash__()`, :meth:`Item.__eq__()`, and
+ :meth:`Item.__ne__()` methods for items to be hashable.
+
+ An example implementation follows::
+
+ class Item:
+ def __init__(self, a, b):
+ self._a = a
+ self._b = b
+
+ @property
+ def a(self):
+ return self._a
+
+ @property
+ def b(self):
+ return self._b
+
+ def _key(self):
+ return (self._a, self._b)
+
+ def __eq__(self, item):
+ return self._key() == item._key()
+
+ def __ne__(self, item):
+ return self._key() != item._key()
+
+ def __hash__(self):
+ return hash(self._key())
+
+ based on the OrderedSetQueue below
+ """
+
+ def __init__(self, maxsize: int = 0) -> None:
+ super().__init__(maxsize)
+ self._last_item = None
+
+ def put(self, item: Any, block: bool = True, timeout: float | None = None) -> None: # noqa: FBT001,FBT002
+ """This method will be used by `eventlet`, when enabled, so we cannot use force proper keyword-only
+ arguments nor touch the signature. Also, the `timeout` argument will be ignored in that case.
+ """
+ if self._last_item is None or item != self._last_item:
+ super().put(item, block, timeout)
+
+ def _put(self, item: Any) -> None:
+ super()._put(item)
+ self._last_item = item
+
+ def _get(self) -> Any:
+ item = super()._get()
+ if item is self._last_item:
+ self._last_item = None
+ return item
diff --git a/src/mcpstore/utils/watchdog/utils/delayed_queue.py b/src/mcpstore/utils/watchdog/utils/delayed_queue.py
new file mode 100644
index 00000000..914dbc00
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/delayed_queue.py
@@ -0,0 +1,94 @@
+""":module: watchdog.utils.delayed_queue
+:author: thomas.amland@gmail.com (Thomas Amland)
+:author: Mickaël Schoentgen
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from collections import deque
+from typing import Callable, Generic, TypeVar
+
+T = TypeVar("T")
+
+
+class DelayedQueue(Generic[T]):
+ def __init__(self, delay: float) -> None:
+ self.delay_sec = delay
+ self._lock = threading.Lock()
+ self._not_empty = threading.Condition(self._lock)
+ self._queue: deque[tuple[T, float, bool]] = deque()
+ self._closed = False
+
+ def put(self, element: T, *, delay: bool = False) -> None:
+ """Add element to queue."""
+ self._lock.acquire()
+ self._queue.append((element, time.time(), delay))
+ self._not_empty.notify()
+ self._lock.release()
+
+ def close(self) -> None:
+ """Close queue, indicating no more items will be added."""
+ self._closed = True
+ # Interrupt the blocking _not_empty.wait() call in get
+ self._not_empty.acquire()
+ self._not_empty.notify()
+ self._not_empty.release()
+
+ def get(self) -> T | None:
+ """Remove and return an element from the queue, or this queue has been
+ closed raise the Closed exception.
+ """
+ while True:
+ # wait for element to be added to queue
+ self._not_empty.acquire()
+ while len(self._queue) == 0 and not self._closed:
+ self._not_empty.wait()
+
+ if self._closed:
+ self._not_empty.release()
+ return None
+ head, insert_time, delay = self._queue[0]
+ self._not_empty.release()
+
+ # wait for delay if required
+ if delay:
+ time_left = insert_time + self.delay_sec - time.time()
+ while time_left > 0:
+ time.sleep(time_left)
+ time_left = insert_time + self.delay_sec - time.time()
+
+ # return element if it's still in the queue
+ with self._lock:
+ if len(self._queue) > 0 and self._queue[0][0] is head:
+ self._queue.popleft()
+ return head
+
+ def find(self, predicate: Callable[[T], bool]) -> T | None:
+ """return the first item for which predicate is True,
+ ignoring delay.
+ """
+ with self._lock:
+ i_item = self._index_and_item(predicate)
+ return i_item[1] if i_item is not None else None
+
+ def remove(self, predicate: Callable[[T], bool]) -> T | None:
+ """Remove and return the first item for which predicate is True,
+ ignoring delay.
+ """
+ with self._lock:
+ i_item = self._index_and_item(predicate)
+ if i_item is not None:
+ del self._queue[i_item[0]]
+ return i_item[1]
+ return None
+
+ def _index_and_item(self, predicate: Callable[[T], bool]) -> tuple[int, T] | None:
+ """Return the index and value of the first item for which predicate is
+ True, ignoring delay. Returns -1 if nothing is found. Requires a lock.
+ """
+ for i, (elem, *_) in enumerate(self._queue):
+ if predicate(elem):
+ return i, elem
+ return None
diff --git a/src/mcpstore/utils/watchdog/utils/dirsnapshot.py b/src/mcpstore/utils/watchdog/utils/dirsnapshot.py
new file mode 100644
index 00000000..e7e51b50
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/dirsnapshot.py
@@ -0,0 +1,427 @@
+""":module: watchdog.utils.dirsnapshot
+:synopsis: Directory snapshots and comparison.
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+
+.. ADMONITION:: Where are the moved events? They "disappeared"
+
+ This implementation does not take partition boundaries
+ into consideration. It will only work when the directory
+ tree is entirely on the same file system. More specifically,
+ any part of the code that depends on inode numbers can
+ break if partition boundaries are crossed. In these cases,
+ the snapshot diff will represent file/directory movement as
+ created and deleted events.
+
+Classes
+-------
+.. autoclass:: DirectorySnapshot
+ :members:
+ :show-inheritance:
+
+.. autoclass:: DirectorySnapshotDiff
+ :members:
+ :show-inheritance:
+
+.. autoclass:: EmptyDirectorySnapshot
+ :members:
+ :show-inheritance:
+
+"""
+
+from __future__ import annotations
+
+import contextlib
+import errno
+import os
+from stat import S_ISDIR
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+ from typing import Any, Callable
+
+
+class DirectorySnapshotDiff:
+ """Compares two directory snapshots and creates an object that represents
+ the difference between the two snapshots.
+
+ :param ref:
+ The reference directory snapshot.
+ :type ref:
+ :class:`DirectorySnapshot`
+ :param snapshot:
+ The directory snapshot which will be compared
+ with the reference snapshot.
+ :type snapshot:
+ :class:`DirectorySnapshot`
+ :param ignore_device:
+ A boolean indicating whether to ignore the device id or not.
+ By default, a file may be uniquely identified by a combination of its first
+ inode and its device id. The problem is that the device id may (or may not)
+ change between system boots. This problem would cause the DirectorySnapshotDiff
+ to think a file has been deleted and created again but it would be the
+ exact same file.
+ Set to True only if you are sure you will always use the same device.
+ :type ignore_device:
+ :class:`bool`
+ """
+
+ def __init__(
+ self,
+ ref: DirectorySnapshot,
+ snapshot: DirectorySnapshot,
+ *,
+ ignore_device: bool = False,
+ ) -> None:
+ created = snapshot.paths - ref.paths
+ deleted = ref.paths - snapshot.paths
+
+ if ignore_device:
+
+ def get_inode(directory: DirectorySnapshot, full_path: bytes | str) -> int | tuple[int, int]:
+ return directory.inode(full_path)[0]
+
+ else:
+
+ def get_inode(directory: DirectorySnapshot, full_path: bytes | str) -> int | tuple[int, int]:
+ return directory.inode(full_path)
+
+ # check that all unchanged paths have the same inode
+ for path in ref.paths & snapshot.paths:
+ if get_inode(ref, path) != get_inode(snapshot, path):
+ created.add(path)
+ deleted.add(path)
+
+ # find moved paths
+ moved: set[tuple[bytes | str, bytes | str]] = set()
+ for path in set(deleted):
+ inode = ref.inode(path)
+ new_path = snapshot.path(inode)
+ if new_path:
+ # file is not deleted but moved
+ deleted.remove(path)
+ moved.add((path, new_path))
+
+ for path in set(created):
+ inode = snapshot.inode(path)
+ old_path = ref.path(inode)
+ if old_path:
+ created.remove(path)
+ moved.add((old_path, path))
+
+ # find modified paths
+ # first check paths that have not moved
+ modified: set[bytes | str] = set()
+ for path in ref.paths & snapshot.paths:
+ if get_inode(ref, path) == get_inode(snapshot, path) and (
+ ref.mtime(path) != snapshot.mtime(path) or ref.size(path) != snapshot.size(path)
+ ):
+ modified.add(path)
+
+ for old_path, new_path in moved:
+ if ref.mtime(old_path) != snapshot.mtime(new_path) or ref.size(old_path) != snapshot.size(new_path):
+ modified.add(old_path)
+
+ self._dirs_created = [path for path in created if snapshot.isdir(path)]
+ self._dirs_deleted = [path for path in deleted if ref.isdir(path)]
+ self._dirs_modified = [path for path in modified if ref.isdir(path)]
+ self._dirs_moved = [(frm, to) for (frm, to) in moved if ref.isdir(frm)]
+
+ self._files_created = list(created - set(self._dirs_created))
+ self._files_deleted = list(deleted - set(self._dirs_deleted))
+ self._files_modified = list(modified - set(self._dirs_modified))
+ self._files_moved = list(moved - set(self._dirs_moved))
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+ def __repr__(self) -> str:
+ fmt = (
+ "<{0} files(created={1}, deleted={2}, modified={3}, moved={4}),"
+ " folders(created={5}, deleted={6}, modified={7}, moved={8})>"
+ )
+ return fmt.format(
+ type(self).__name__,
+ len(self._files_created),
+ len(self._files_deleted),
+ len(self._files_modified),
+ len(self._files_moved),
+ len(self._dirs_created),
+ len(self._dirs_deleted),
+ len(self._dirs_modified),
+ len(self._dirs_moved),
+ )
+
+ def __len__(self) -> int:
+ return sum(len(getattr(self, attr)) for attr in dir(self) if attr.startswith(("_dirs_", "_files_")))
+
+ @property
+ def files_created(self) -> list[bytes | str]:
+ """List of files that were created."""
+ return self._files_created
+
+ @property
+ def files_deleted(self) -> list[bytes | str]:
+ """List of files that were deleted."""
+ return self._files_deleted
+
+ @property
+ def files_modified(self) -> list[bytes | str]:
+ """List of files that were modified."""
+ return self._files_modified
+
+ @property
+ def files_moved(self) -> list[tuple[bytes | str, bytes | str]]:
+ """List of files that were moved.
+
+ Each event is a two-tuple the first item of which is the path
+ that has been renamed to the second item in the tuple.
+ """
+ return self._files_moved
+
+ @property
+ def dirs_modified(self) -> list[bytes | str]:
+ """List of directories that were modified."""
+ return self._dirs_modified
+
+ @property
+ def dirs_moved(self) -> list[tuple[bytes | str, bytes | str]]:
+ """List of directories that were moved.
+
+ Each event is a two-tuple the first item of which is the path
+ that has been renamed to the second item in the tuple.
+ """
+ return self._dirs_moved
+
+ @property
+ def dirs_deleted(self) -> list[bytes | str]:
+ """List of directories that were deleted."""
+ return self._dirs_deleted
+
+ @property
+ def dirs_created(self) -> list[bytes | str]:
+ """List of directories that were created."""
+ return self._dirs_created
+
+ class ContextManager:
+ """Context manager that creates two directory snapshots and a
+ diff object that represents the difference between the two snapshots.
+
+ :param path:
+ The directory path for which a snapshot should be taken.
+ :type path:
+ ``str``
+ :param recursive:
+ ``True`` if the entire directory tree should be included in the
+ snapshot; ``False`` otherwise.
+ :type recursive:
+ ``bool``
+ :param stat:
+ Use custom stat function that returns a stat structure for path.
+ Currently only st_dev, st_ino, st_mode and st_mtime are needed.
+
+ A function taking a ``path`` as argument which will be called
+ for every entry in the directory tree.
+ :param listdir:
+ Use custom listdir function. For details see ``os.scandir``.
+ :param ignore_device:
+ A boolean indicating whether to ignore the device id or not.
+ By default, a file may be uniquely identified by a combination of its first
+ inode and its device id. The problem is that the device id may (or may not)
+ change between system boots. This problem would cause the DirectorySnapshotDiff
+ to think a file has been deleted and created again but it would be the
+ exact same file.
+ Set to True only if you are sure you will always use the same device.
+ :type ignore_device:
+ :class:`bool`
+ """
+
+ def __init__(
+ self,
+ path: str,
+ *,
+ recursive: bool = True,
+ stat: Callable[[str], os.stat_result] = os.stat,
+ listdir: Callable[[str | None], Iterator[os.DirEntry]] = os.scandir,
+ ignore_device: bool = False,
+ ) -> None:
+ self.path = path
+ self.recursive = recursive
+ self.stat = stat
+ self.listdir = listdir
+ self.ignore_device = ignore_device
+
+ def __enter__(self) -> None:
+ self.pre_snapshot = self.get_snapshot()
+
+ def __exit__(self, *args: object) -> None:
+ self.post_snapshot = self.get_snapshot()
+ self.diff = DirectorySnapshotDiff(
+ self.pre_snapshot,
+ self.post_snapshot,
+ ignore_device=self.ignore_device,
+ )
+
+ def get_snapshot(self) -> DirectorySnapshot:
+ return DirectorySnapshot(
+ path=self.path,
+ recursive=self.recursive,
+ stat=self.stat,
+ listdir=self.listdir,
+ )
+
+
+class DirectorySnapshot:
+ """A snapshot of stat information of files in a directory.
+
+ :param path:
+ The directory path for which a snapshot should be taken.
+ :type path:
+ ``str``
+ :param recursive:
+ ``True`` if the entire directory tree should be included in the
+ snapshot; ``False`` otherwise.
+ :type recursive:
+ ``bool``
+ :param stat:
+ Use custom stat function that returns a stat structure for path.
+ Currently only st_dev, st_ino, st_mode and st_mtime are needed.
+
+ A function taking a ``path`` as argument which will be called
+ for every entry in the directory tree.
+ :param listdir:
+ Use custom listdir function. For details see ``os.scandir``.
+ """
+
+ def __init__(
+ self,
+ path: str,
+ *,
+ recursive: bool = True,
+ stat: Callable[[str], os.stat_result] = os.stat,
+ listdir: Callable[[str | None], Iterator[os.DirEntry]] = os.scandir,
+ ) -> None:
+ self.recursive = recursive
+ self.stat = stat
+ self.listdir = listdir
+
+ self._stat_info: dict[bytes | str, os.stat_result] = {}
+ self._inode_to_path: dict[tuple[int, int], bytes | str] = {}
+
+ st = self.stat(path)
+ self._stat_info[path] = st
+ self._inode_to_path[(st.st_ino, st.st_dev)] = path
+
+ for p, st in self.walk(path):
+ i = (st.st_ino, st.st_dev)
+ self._inode_to_path[i] = p
+ self._stat_info[p] = st
+
+ def walk(self, root: str) -> Iterator[tuple[str, os.stat_result]]:
+ try:
+ paths = [os.path.join(root, entry.name) for entry in self.listdir(root)]
+ except OSError as e:
+ # Directory may have been deleted between finding it in the directory
+ # list of its parent and trying to delete its contents. If this
+ # happens we treat it as empty. Likewise if the directory was replaced
+ # with a file of the same name (less likely, but possible).
+ if e.errno in (errno.ENOENT, errno.ENOTDIR, errno.EINVAL):
+ return
+ else:
+ raise
+
+ entries = []
+ for p in paths:
+ with contextlib.suppress(OSError):
+ entry = (p, self.stat(p))
+ entries.append(entry)
+ yield entry
+
+ if self.recursive:
+ for path, st in entries:
+ with contextlib.suppress(PermissionError):
+ if S_ISDIR(st.st_mode):
+ yield from self.walk(path)
+
+ @property
+ def paths(self) -> set[bytes | str]:
+ """Set of file/directory paths in the snapshot."""
+ return set(self._stat_info.keys())
+
+ def path(self, uid: tuple[int, int]) -> bytes | str | None:
+ """Returns path for id. None if id is unknown to this snapshot."""
+ return self._inode_to_path.get(uid)
+
+ def inode(self, path: bytes | str) -> tuple[int, int]:
+ """Returns an id for path."""
+ st = self._stat_info[path]
+ return (st.st_ino, st.st_dev)
+
+ def isdir(self, path: bytes | str) -> bool:
+ return S_ISDIR(self._stat_info[path].st_mode)
+
+ def mtime(self, path: bytes | str) -> float:
+ return self._stat_info[path].st_mtime
+
+ def size(self, path: bytes | str) -> int:
+ return self._stat_info[path].st_size
+
+ def stat_info(self, path: bytes | str) -> os.stat_result:
+ """Returns a stat information object for the specified path from
+ the snapshot.
+
+ Attached information is subject to change. Do not use unless
+ you specify `stat` in constructor. Use :func:`inode`, :func:`mtime`,
+ :func:`isdir` instead.
+
+ :param path:
+ The path for which stat information should be obtained
+ from a snapshot.
+ """
+ return self._stat_info[path]
+
+ def __sub__(self, previous_dirsnap: DirectorySnapshot) -> DirectorySnapshotDiff:
+ """Allow subtracting a DirectorySnapshot object instance from
+ another.
+
+ :returns:
+ A :class:`DirectorySnapshotDiff` object.
+ """
+ return DirectorySnapshotDiff(previous_dirsnap, self)
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+ def __repr__(self) -> str:
+ return str(self._stat_info)
+
+
+class EmptyDirectorySnapshot(DirectorySnapshot):
+ """Class to implement an empty snapshot. This is used together with
+ DirectorySnapshot and DirectorySnapshotDiff in order to get all the files/folders
+ in the directory as created.
+ """
+
+ def __init__(self) -> None:
+ self._stat_info: dict[bytes | str, os.stat_result] = {}
+
+ @staticmethod
+ def path(_: Any) -> None:
+ """Mock up method to return the path of the received inode. As the snapshot
+ is intended to be empty, it always returns None.
+
+ :returns:
+ None.
+ """
+ return
+
+ @property
+ def paths(self) -> set:
+ """Mock up method to return a set of file/directory paths in the snapshot. As
+ the snapshot is intended to be empty, it always returns an empty set.
+
+ :returns:
+ An empty set.
+ """
+ return set()
diff --git a/src/mcpstore/utils/watchdog/utils/echo.py b/src/mcpstore/utils/watchdog/utils/echo.py
new file mode 100644
index 00000000..4ff9217d
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/echo.py
@@ -0,0 +1,68 @@
+# echo.py: Tracing function calls using Python decorators.
+#
+# Written by Thomas Guest
+# Please see http://wordaligned.org/articles/echo
+#
+# Place into the public domain.
+
+"""Echo calls made to functions in a module.
+
+"Echoing" a function call means printing out the name of the function
+and the values of its arguments before making the call (which is more
+commonly referred to as "tracing", but Python already has a trace module).
+
+Alternatively, echo.echo can be used to decorate functions. Calls to the
+decorated function will be echoed.
+
+Example:
+-------
+
+ @echo.echo
+ def my_function(args):
+ pass
+
+"""
+
+from __future__ import annotations
+
+import functools
+import sys
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import Any, Callable
+
+
+def format_arg_value(arg_val: tuple[str, tuple[Any, ...]]) -> str:
+ """Return a string representing a (name, value) pair."""
+ arg, val = arg_val
+ return f"{arg}={val!r}"
+
+
+def echo(fn: Callable, write: Callable[[str], int | None] = sys.stdout.write) -> Callable:
+ """Echo calls to a function.
+
+ Returns a decorated version of the input function which "echoes" calls
+ made to it by writing out the function's name and the arguments it was
+ called with.
+ """
+ # Unpack function's arg count, arg names, arg defaults
+ code = fn.__code__
+ argcount = code.co_argcount
+ argnames = code.co_varnames[:argcount]
+ fn_defaults: tuple[Any] = fn.__defaults__ or ()
+ argdefs = dict(list(zip(argnames[-len(fn_defaults) :], fn_defaults)))
+
+ @functools.wraps(fn)
+ def wrapped(*v: Any, **k: Any) -> Callable:
+ # Collect function arguments by chaining together positional,
+ # defaulted, extra positional and keyword arguments.
+ positional = list(map(format_arg_value, list(zip(argnames, v))))
+ defaulted = [format_arg_value((a, argdefs[a])) for a in argnames[len(v) :] if a not in k]
+ nameless = list(map(repr, v[argcount:]))
+ keyword = list(map(format_arg_value, list(k.items())))
+ args = positional + defaulted + nameless + keyword
+ write(f"{fn.__name__}({', '.join(args)})\n")
+ return fn(*v, **k)
+
+ return wrapped
diff --git a/src/mcpstore/utils/watchdog/utils/event_debouncer.py b/src/mcpstore/utils/watchdog/utils/event_debouncer.py
new file mode 100644
index 00000000..a9a6342f
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/event_debouncer.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import logging
+import threading
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.utils import BaseThread
+
+if TYPE_CHECKING:
+ from typing import Callable
+
+ from mcpstore.utils.watchdog.events import FileSystemEvent
+
+logger = logging.getLogger(__name__)
+
+
+class EventDebouncer(BaseThread):
+ """Background thread for debouncing event handling.
+
+ When an event is received, wait until the configured debounce interval
+ passes before calling the callback. If additional events are received
+ before the interval passes, reset the timer and keep waiting. When the
+ debouncing interval passes, the callback will be called with a list of
+ events in the order in which they were received.
+ """
+
+ def __init__(
+ self,
+ debounce_interval_seconds: int,
+ events_callback: Callable[[list[FileSystemEvent]], None],
+ ) -> None:
+ super().__init__()
+ self.debounce_interval_seconds = debounce_interval_seconds
+ self.events_callback = events_callback
+
+ self._events: list[FileSystemEvent] = []
+ self._cond = threading.Condition()
+
+ def handle_event(self, event: FileSystemEvent) -> None:
+ with self._cond:
+ self._events.append(event)
+ self._cond.notify()
+
+ def stop(self) -> None:
+ with self._cond:
+ super().stop()
+ self._cond.notify()
+
+ def run(self) -> None:
+ with self._cond:
+ while True:
+ # Wait for first event (or shutdown).
+ self._cond.wait()
+
+ if self.debounce_interval_seconds:
+ # Wait for additional events (or shutdown) until the debounce interval passes.
+ while self.should_keep_running():
+ if not self._cond.wait(timeout=self.debounce_interval_seconds):
+ break
+
+ if not self.should_keep_running():
+ break
+
+ events = self._events
+ self._events = []
+ self.events_callback(events)
diff --git a/src/mcpstore/utils/watchdog/utils/patterns.py b/src/mcpstore/utils/watchdog/utils/patterns.py
new file mode 100644
index 00000000..95a38c0b
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/patterns.py
@@ -0,0 +1,131 @@
+""":module: watchdog.utils.patterns
+:synopsis: Common wildcard searching/filtering functionality for files.
+:author: boris.staletic@gmail.com (Boris Staletic)
+:author: yesudeep@gmail.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+"""
+
+from __future__ import annotations
+
+# Non-pure path objects are only allowed on their respective OS's.
+# Thus, these utilities require "pure" path objects that don't access the filesystem.
+# Since pathlib doesn't have a `case_sensitive` parameter, we have to approximate it
+# by converting input paths to `PureWindowsPath` and `PurePosixPath` where:
+# - `PureWindowsPath` is always case-insensitive.
+# - `PurePosixPath` is always case-sensitive.
+# Reference: https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.match
+import re
+from pathlib import PurePath, PurePosixPath, PureWindowsPath
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.utils.backwards_compat import translate # type: ignore[attr-defined]
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+
+def _get_sep(path: PurePath) -> str:
+ """
+ Python < 3.13 doesn't have a clean way to expose the path separator
+ It's either this, or make use of `path._flavour.sep`
+ """
+ if isinstance(path, PureWindowsPath):
+ return "\\"
+ if isinstance(path, PurePosixPath):
+ return "/"
+ raise TypeError("Unsupported")
+
+
+def _full_match(path: PurePath, pattern: str) -> bool:
+ try:
+ return path.full_match(pattern)
+ except AttributeError:
+ # Replicate for python <3.13
+ # Please remove this, backwards_compat.py, and python license attributions
+ # if/when we can pin a release to python >= 3.13
+ # Construct a pathlib object using the same class as the path to get the
+ # same pattern path separater when constructing the regex
+ normalized_pattern = str(type(path)(pattern))
+ regex = translate(normalized_pattern, recursive=True, include_hidden=True, seps=_get_sep(path))
+ reobj = re.compile(regex)
+ return bool(reobj.match(str(path)))
+
+
+def _match_path(
+ raw_path: str,
+ included_patterns: set[str],
+ excluded_patterns: set[str],
+ *,
+ case_sensitive: bool,
+) -> bool:
+ """Internal function same as :func:`match_path` but does not check arguments."""
+ path: PurePosixPath | PureWindowsPath
+ if case_sensitive:
+ path = PurePosixPath(raw_path)
+ else:
+ included_patterns = {pattern.lower() for pattern in included_patterns}
+ excluded_patterns = {pattern.lower() for pattern in excluded_patterns}
+ path = PureWindowsPath(raw_path)
+
+ common_patterns = included_patterns & excluded_patterns
+ if common_patterns:
+ error = f"conflicting patterns `{common_patterns}` included and excluded"
+ raise ValueError(error)
+
+ return any(_full_match(path, p) for p in included_patterns) and not any(
+ _full_match(path, p) for p in excluded_patterns
+ )
+
+
+def filter_paths(
+ paths: list[str],
+ *,
+ included_patterns: list[str] | None = None,
+ excluded_patterns: list[str] | None = None,
+ case_sensitive: bool = True,
+) -> Iterator[str]:
+ """Filters from a set of paths based on acceptable patterns and
+ ignorable patterns.
+ :param paths:
+ A list of path names that will be filtered based on matching and
+ ignored patterns.
+ :param included_patterns:
+ Allow filenames matching wildcard patterns specified in this list.
+ If no pattern list is specified, ["**"] is used as the default pattern,
+ which matches all files.
+ :param excluded_patterns:
+ Ignores filenames matching wildcard patterns specified in this list.
+ If no pattern list is specified, no files are ignored.
+ :param case_sensitive:
+ ``True`` if matching should be case-sensitive; ``False`` otherwise.
+ :returns:
+ A list of pathnames that matched the allowable patterns and passed
+ through the ignored patterns.
+ """
+ included = set(["**"] if included_patterns is None else included_patterns)
+ excluded = set([] if excluded_patterns is None else excluded_patterns)
+
+ for path in paths:
+ if _match_path(path, included, excluded, case_sensitive=case_sensitive):
+ yield path
+
+
+def match_any_paths(
+ paths: list[str],
+ *,
+ included_patterns: list[str] | None = None,
+ excluded_patterns: list[str] | None = None,
+ case_sensitive: bool = True,
+) -> bool:
+ """Matches from a set of paths based on acceptable patterns and
+ ignorable patterns.
+ See ``filter_paths()`` for signature details.
+ """
+ return any(
+ filter_paths(
+ paths,
+ included_patterns=included_patterns,
+ excluded_patterns=excluded_patterns,
+ case_sensitive=case_sensitive,
+ ),
+ )
diff --git a/src/mcpstore/utils/watchdog/utils/platform.py b/src/mcpstore/utils/watchdog/utils/platform.py
new file mode 100644
index 00000000..3c11d152
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/platform.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import sys
+
+PLATFORM_WINDOWS = "windows"
+PLATFORM_LINUX = "linux"
+PLATFORM_BSD = "bsd"
+PLATFORM_DARWIN = "darwin"
+PLATFORM_UNKNOWN = "unknown"
+
+
+def get_platform_name() -> str:
+ if sys.platform.startswith("win"):
+ return PLATFORM_WINDOWS
+
+ if sys.platform.startswith("darwin"):
+ return PLATFORM_DARWIN
+
+ if sys.platform.startswith("linux"):
+ return PLATFORM_LINUX
+
+ if sys.platform.startswith(("dragonfly", "freebsd", "netbsd", "openbsd", "bsd")):
+ return PLATFORM_BSD
+
+ return PLATFORM_UNKNOWN
+
+
+__platform__ = get_platform_name()
+
+
+def is_linux() -> bool:
+ return __platform__ == PLATFORM_LINUX
+
+
+def is_bsd() -> bool:
+ return __platform__ == PLATFORM_BSD
+
+
+def is_darwin() -> bool:
+ return __platform__ == PLATFORM_DARWIN
+
+
+def is_windows() -> bool:
+ return __platform__ == PLATFORM_WINDOWS
diff --git a/src/mcpstore/utils/watchdog/utils/process_watcher.py b/src/mcpstore/utils/watchdog/utils/process_watcher.py
new file mode 100644
index 00000000..edbb045b
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/utils/process_watcher.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from mcpstore.utils.watchdog.utils import BaseThread
+
+if TYPE_CHECKING:
+ import subprocess
+ from typing import Callable
+
+logger = logging.getLogger(__name__)
+
+
+class ProcessWatcher(BaseThread):
+ def __init__(self, popen_obj: subprocess.Popen, process_termination_callback: Callable[[], None] | None) -> None:
+ super().__init__()
+ self.popen_obj = popen_obj
+ self.process_termination_callback = process_termination_callback
+
+ def run(self) -> None:
+ while self.popen_obj.poll() is None:
+ if self.stopped_event.wait(timeout=0.1):
+ return
+
+ try:
+ if not self.stopped_event.is_set() and self.process_termination_callback:
+ self.process_termination_callback()
+ except Exception:
+ logger.exception("Error calling process termination callback")
diff --git a/src/mcpstore/utils/watchdog/version.py b/src/mcpstore/utils/watchdog/version.py
new file mode 100644
index 00000000..77342266
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/version.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+# When updating this version number, please update the
+# ``docs/source/global.rst.inc`` file as well.
+VERSION_MAJOR = 7
+VERSION_MINOR = 0
+VERSION_BUILD = 0
+VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
+VERSION_STRING = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}"
+
+__version__ = VERSION_INFO
diff --git a/src/mcpstore/utils/watchdog/watchmedo.py b/src/mcpstore/utils/watchdog/watchmedo.py
new file mode 100644
index 00000000..db1cf237
--- /dev/null
+++ b/src/mcpstore/utils/watchdog/watchmedo.py
@@ -0,0 +1,807 @@
+""":module: watchdog.watchmedo
+:author: yesudeep@google.com (Yesudeep Mangalapilly)
+:author: Mickaël Schoentgen
+:synopsis: ``watchmedo`` shell script utility.
+"""
+
+from __future__ import annotations
+
+import errno
+import logging
+import os
+import os.path
+import sys
+import time
+from argparse import ArgumentParser, RawDescriptionHelpFormatter
+from io import StringIO
+from textwrap import dedent
+from typing import TYPE_CHECKING, Any
+
+from mcpstore.utils.watchdog.utils import WatchdogShutdownError, load_class, platform
+from mcpstore.utils.watchdog.version import VERSION_STRING
+
+if TYPE_CHECKING:
+ from argparse import Namespace, _SubParsersAction
+ from typing import Callable
+
+ from mcpstore.utils.watchdog.events import FileSystemEventHandler
+ from mcpstore.utils.watchdog.observers import ObserverType
+ from mcpstore.utils.watchdog.observers.api import BaseObserver
+
+
+logging.basicConfig(level=logging.INFO)
+
+CONFIG_KEY_TRICKS = "tricks"
+CONFIG_KEY_PYTHON_PATH = "python-path"
+
+
+class HelpFormatter(RawDescriptionHelpFormatter):
+ """A nicer help formatter.
+
+ Help for arguments can be indented and contain new lines.
+ It will be de-dented and arguments in the help
+ will be separated by a blank line for better readability.
+
+ Source: https://github.com/httpie/httpie/blob/2423f89/httpie/cli/argparser.py#L31
+ """
+
+ def __init__(self, *args: Any, max_help_position: int = 6, **kwargs: Any) -> None:
+ # A smaller indent for args help.
+ kwargs["max_help_position"] = max_help_position
+ super().__init__(*args, **kwargs)
+
+ def __repr__(self) -> str:
+ return f"<{type(self).__name__}>"
+
+ def _split_lines(self, text: str, width: int) -> list[str]:
+ text = dedent(text).strip() + "\n\n"
+ return text.splitlines()
+
+
+epilog = """\
+Copyright 2018-2025 Mickaël Schoentgen & contributors
+Copyright 2014-2018 Thomas Amland & contributors
+Copyright 2012-2014 Google, Inc.
+Copyright 2011-2012 Yesudeep Mangalapilly
+
+Licensed under the terms of the Apache license, version 2.0. Please see
+LICENSE in the source code for more information."""
+
+cli = ArgumentParser(epilog=epilog, formatter_class=HelpFormatter)
+cli.add_argument("--version", action="version", version=VERSION_STRING)
+subparsers = cli.add_subparsers(dest="top_command")
+command_parsers = {}
+
+Argument = tuple[list[str], Any]
+
+
+def argument(*name_or_flags: str, **kwargs: Any) -> Argument:
+ """Convenience function to properly format arguments to pass to the
+ command decorator.
+ """
+ return list(name_or_flags), kwargs
+
+
+def command(
+ args: list[Argument],
+ *,
+ parent: _SubParsersAction[ArgumentParser] = subparsers,
+ cmd_aliases: list[str] | None = None,
+) -> Callable:
+ """Decorator to define a new command in a sanity-preserving way.
+ The function will be stored in the ``func`` variable when the parser
+ parses arguments so that it can be called directly like so::
+
+ >>> args = cli.parse_args()
+ >>> args.func(args)
+
+ """
+
+ def decorator(func: Callable) -> Callable:
+ name = func.__name__.replace("_", "-")
+ desc = dedent(func.__doc__ or "")
+ parser = parent.add_parser(name, aliases=cmd_aliases or [], description=desc, formatter_class=HelpFormatter)
+ command_parsers[name] = parser
+ verbosity_group = parser.add_mutually_exclusive_group()
+ verbosity_group.add_argument("-q", "--quiet", dest="verbosity", action="append_const", const=-1)
+ verbosity_group.add_argument("-v", "--verbose", dest="verbosity", action="append_const", const=1)
+ for name_or_flags, kwargs in args:
+ parser.add_argument(*name_or_flags, **kwargs)
+ parser.set_defaults(func=func)
+ return func
+
+ return decorator
+
+
+def path_split(pathname_spec: str, *, separator: str = os.pathsep) -> list[str]:
+ """Splits a pathname specification separated by an OS-dependent separator.
+
+ :param pathname_spec:
+ The pathname specification.
+ :param separator:
+ (OS Dependent) `:` on Unix and `;` on Windows or user-specified.
+ """
+ return pathname_spec.split(separator)
+
+
+def add_to_sys_path(pathnames: list[str], *, index: int = 0) -> None:
+ """Adds specified paths at specified index into the sys.path list.
+
+ :param paths:
+ A list of paths to add to the sys.path
+ :param index:
+ (Default 0) The index in the sys.path list where the paths will be
+ added.
+ """
+ for pathname in pathnames[::-1]:
+ sys.path.insert(index, pathname)
+
+
+def load_config(tricks_file_pathname: str) -> dict:
+ """Loads the YAML configuration from the specified file.
+
+ :param tricks_file_path:
+ The path to the tricks configuration file.
+ :returns:
+ A dictionary of configuration information.
+ """
+ import yaml
+
+ with open(tricks_file_pathname, "rb") as f:
+ return yaml.safe_load(f.read())
+
+
+def parse_patterns(
+ patterns_spec: str, ignore_patterns_spec: str, *, separator: str = ";"
+) -> tuple[list[str], list[str]]:
+ """Parses pattern argument specs and returns a two-tuple of
+ (patterns, ignore_patterns).
+ """
+ patterns = patterns_spec.split(separator)
+ ignore_patterns = ignore_patterns_spec.split(separator)
+ if ignore_patterns == [""]:
+ ignore_patterns = []
+ return patterns, ignore_patterns
+
+
+def observe_with(
+ observer: BaseObserver,
+ event_handler: FileSystemEventHandler,
+ pathnames: list[str],
+ *,
+ recursive: bool,
+) -> None:
+ """Single observer thread with a scheduled path and event handler.
+
+ :param observer:
+ The observer thread.
+ :param event_handler:
+ Event handler which will be called in response to file system events.
+ :param pathnames:
+ A list of pathnames to monitor.
+ :param recursive:
+ ``True`` if recursive; ``False`` otherwise.
+ """
+ for pathname in set(pathnames):
+ observer.schedule(event_handler, pathname, recursive=recursive)
+ observer.start()
+ try:
+ while True:
+ time.sleep(1)
+ except WatchdogShutdownError:
+ observer.stop()
+ observer.join()
+
+
+def schedule_tricks(observer: BaseObserver, tricks: list[dict], pathname: str, *, recursive: bool) -> None:
+ """Schedules tricks with the specified observer and for the given watch
+ path.
+
+ :param observer:
+ The observer thread into which to schedule the trick and watch.
+ :param tricks:
+ A list of tricks.
+ :param pathname:
+ A path name which should be watched.
+ :param recursive:
+ ``True`` if recursive; ``False`` otherwise.
+ """
+ for trick in tricks:
+ for name, value in trick.items():
+ trick_cls = load_class(name)
+ handler = trick_cls(**value)
+ trick_pathname = getattr(handler, "source_directory", None) or pathname
+ observer.schedule(handler, trick_pathname, recursive=recursive)
+
+
+@command(
+ [
+ argument("files", nargs="*", help="perform tricks from given file"),
+ argument(
+ "--python-path",
+ default=".",
+ help=f"Paths separated by {os.pathsep!r} to add to the Python path.",
+ ),
+ argument(
+ "--interval",
+ "--timeout",
+ dest="timeout",
+ default=1.0,
+ type=float,
+ help="Use this as the polling interval/blocking timeout (in seconds).",
+ ),
+ argument(
+ "--recursive",
+ action="store_true",
+ default=True,
+ help="Recursively monitor paths (defaults to True).",
+ ),
+ argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."),
+ argument(
+ "--debug-force-kqueue",
+ action="store_true",
+ help="[debug] Forces BSD kqueue(2).",
+ ),
+ argument(
+ "--debug-force-winapi",
+ action="store_true",
+ help="[debug] Forces Windows API.",
+ ),
+ argument(
+ "--debug-force-fsevents",
+ action="store_true",
+ help="[debug] Forces macOS FSEvents.",
+ ),
+ argument(
+ "--debug-force-inotify",
+ action="store_true",
+ help="[debug] Forces Linux inotify(7).",
+ ),
+ ],
+ cmd_aliases=["tricks"],
+)
+def tricks_from(args: Namespace) -> None:
+ """Command to execute tricks from a tricks configuration file."""
+ observer_cls: ObserverType
+ if args.debug_force_polling:
+ from mcpstore.utils.watchdog.observers.polling import PollingObserver
+
+ observer_cls = PollingObserver
+ elif args.debug_force_kqueue:
+ from mcpstore.utils.watchdog.observers.kqueue import KqueueObserver
+
+ observer_cls = KqueueObserver
+ elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and platform.is_windows()):
+ from mcpstore.utils.watchdog.observers.read_directory_changes import WindowsApiObserver
+
+ observer_cls = WindowsApiObserver
+ elif args.debug_force_inotify:
+ from mcpstore.utils.watchdog.observers.inotify import InotifyObserver
+
+ observer_cls = InotifyObserver
+ elif args.debug_force_fsevents:
+ from mcpstore.utils.watchdog.observers.fsevents import FSEventsObserver
+
+ observer_cls = FSEventsObserver
+ else:
+ # Automatically picks the most appropriate observer for the platform
+ # on which it is running.
+ from mcpstore.utils.watchdog.observers import Observer
+
+ observer_cls = Observer
+
+ add_to_sys_path(path_split(args.python_path))
+ observers = []
+ for tricks_file in args.files:
+ observer = observer_cls(timeout=args.timeout)
+
+ if not os.path.exists(tricks_file):
+ raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), tricks_file)
+
+ config = load_config(tricks_file)
+
+ try:
+ tricks = config[CONFIG_KEY_TRICKS]
+ except KeyError as e:
+ error = f"No {CONFIG_KEY_TRICKS!r} key specified in {tricks_file!r}."
+ raise KeyError(error) from e
+
+ if CONFIG_KEY_PYTHON_PATH in config:
+ add_to_sys_path(config[CONFIG_KEY_PYTHON_PATH])
+
+ dir_path = os.path.dirname(tricks_file) or os.path.relpath(os.getcwd())
+ schedule_tricks(observer, tricks, dir_path, recursive=args.recursive)
+ observer.start()
+ observers.append(observer)
+
+ try:
+ while True:
+ time.sleep(1)
+ except WatchdogShutdownError:
+ for o in observers:
+ o.unschedule_all()
+ o.stop()
+ for o in observers:
+ o.join()
+
+
+@command(
+ [
+ argument(
+ "trick_paths",
+ nargs="*",
+ help="Dotted paths for all the tricks you want to generate.",
+ ),
+ argument(
+ "--python-path",
+ default=".",
+ help=f"Paths separated by {os.pathsep!r} to add to the Python path.",
+ ),
+ argument(
+ "--append-to-file",
+ default=None,
+ help="""
+ Appends the generated tricks YAML to a file.
+ If not specified, prints to standard output.""",
+ ),
+ argument(
+ "-a",
+ "--append-only",
+ dest="append_only",
+ action="store_true",
+ help="""
+ If --append-to-file is not specified, produces output for
+ appending instead of a complete tricks YAML file.""",
+ ),
+ ],
+ cmd_aliases=["generate-tricks-yaml"],
+)
+def tricks_generate_yaml(args: Namespace) -> None:
+ """Command to generate Yaml configuration for tricks named on the command line."""
+ import yaml
+
+ python_paths = path_split(args.python_path)
+ add_to_sys_path(python_paths)
+ output = StringIO()
+
+ for trick_path in args.trick_paths:
+ trick_cls = load_class(trick_path)
+ output.write(trick_cls.generate_yaml())
+
+ content = output.getvalue()
+ output.close()
+
+ header = yaml.dump({CONFIG_KEY_PYTHON_PATH: python_paths})
+ header += f"{CONFIG_KEY_TRICKS}:\n"
+ if args.append_to_file is None:
+ # Output to standard output.
+ if not args.append_only:
+ content = header + content
+ sys.stdout.write(content)
+ else:
+ if not os.path.exists(args.append_to_file):
+ content = header + content
+ with open(args.append_to_file, "a", encoding="utf-8") as file:
+ file.write(content)
+
+
+@command(
+ [
+ argument(
+ "directories",
+ nargs="*",
+ default=".",
+ help="Directories to watch. (default: '.').",
+ ),
+ argument(
+ "-p",
+ "--pattern",
+ "--patterns",
+ dest="patterns",
+ default="*",
+ help="Matches event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-i",
+ "--ignore-pattern",
+ "--ignore-patterns",
+ dest="ignore_patterns",
+ default="",
+ help="Ignores event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-D",
+ "--ignore-directories",
+ dest="ignore_directories",
+ action="store_true",
+ help="Ignores events for directories.",
+ ),
+ argument(
+ "-R",
+ "--recursive",
+ dest="recursive",
+ action="store_true",
+ help="Monitors the directories recursively.",
+ ),
+ argument(
+ "--interval",
+ "--timeout",
+ dest="timeout",
+ default=1.0,
+ type=float,
+ help="Use this as the polling interval/blocking timeout.",
+ ),
+ argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."),
+ argument(
+ "--debug-force-kqueue",
+ action="store_true",
+ help="[debug] Forces BSD kqueue(2).",
+ ),
+ argument(
+ "--debug-force-winapi",
+ action="store_true",
+ help="[debug] Forces Windows API.",
+ ),
+ argument(
+ "--debug-force-fsevents",
+ action="store_true",
+ help="[debug] Forces macOS FSEvents.",
+ ),
+ argument(
+ "--debug-force-inotify",
+ action="store_true",
+ help="[debug] Forces Linux inotify(7).",
+ ),
+ ],
+)
+def log(args: Namespace) -> None:
+ """Command to log file system events to the console."""
+ from mcpstore.utils.watchdog.tricks import LoggerTrick
+
+ patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
+ handler = LoggerTrick(
+ patterns=patterns,
+ ignore_patterns=ignore_patterns,
+ ignore_directories=args.ignore_directories,
+ )
+
+ observer_cls: ObserverType
+ if args.debug_force_polling:
+ from mcpstore.utils.watchdog.observers.polling import PollingObserver
+
+ observer_cls = PollingObserver
+ elif args.debug_force_kqueue:
+ from mcpstore.utils.watchdog.observers.kqueue import KqueueObserver
+
+ observer_cls = KqueueObserver
+ elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and platform.is_windows()):
+ from mcpstore.utils.watchdog.observers.read_directory_changes import WindowsApiObserver
+
+ observer_cls = WindowsApiObserver
+ elif args.debug_force_inotify:
+ from mcpstore.utils.watchdog.observers.inotify import InotifyObserver
+
+ observer_cls = InotifyObserver
+ elif args.debug_force_fsevents:
+ from mcpstore.utils.watchdog.observers.fsevents import FSEventsObserver
+
+ observer_cls = FSEventsObserver
+ else:
+ # Automatically picks the most appropriate observer for the platform
+ # on which it is running.
+ from mcpstore.utils.watchdog.observers import Observer
+
+ observer_cls = Observer
+
+ observer = observer_cls(timeout=args.timeout)
+ observe_with(observer, handler, args.directories, recursive=args.recursive)
+
+
+@command(
+ [
+ argument("directories", nargs="*", default=".", help="Directories to watch."),
+ argument(
+ "-c",
+ "--command",
+ dest="command",
+ default=None,
+ help="""
+ Shell command executed in response to matching events.
+ These interpolation variables are available to your command string:
+
+ ${watch_src_path} - event source path
+ ${watch_dest_path} - event destination path (for moved events)
+ ${watch_event_type} - event type
+ ${watch_object} - 'file' or 'directory'
+
+ Note:
+ Please ensure you do not use double quotes (") to quote
+ your command string. That will force your shell to
+ interpolate before the command is processed by this
+ command.
+
+ Example:
+
+ --command='echo "${watch_src_path}"'
+ """,
+ ),
+ argument(
+ "-p",
+ "--pattern",
+ "--patterns",
+ dest="patterns",
+ default="*",
+ help="Matches event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-i",
+ "--ignore-pattern",
+ "--ignore-patterns",
+ dest="ignore_patterns",
+ default="",
+ help="Ignores event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-D",
+ "--ignore-directories",
+ dest="ignore_directories",
+ default=False,
+ action="store_true",
+ help="Ignores events for directories.",
+ ),
+ argument(
+ "-R",
+ "--recursive",
+ dest="recursive",
+ action="store_true",
+ help="Monitors the directories recursively.",
+ ),
+ argument(
+ "--interval",
+ "--timeout",
+ dest="timeout",
+ default=1.0,
+ type=float,
+ help="Use this as the polling interval/blocking timeout.",
+ ),
+ argument(
+ "-w",
+ "--wait",
+ dest="wait_for_process",
+ action="store_true",
+ help="Wait for process to finish to avoid multiple simultaneous instances.",
+ ),
+ argument(
+ "-W",
+ "--drop",
+ dest="drop_during_process",
+ action="store_true",
+ help="Ignore events that occur while command is still being"
+ " executed to avoid multiple simultaneous instances.",
+ ),
+ argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."),
+ ],
+)
+def shell_command(args: Namespace) -> None:
+ """Command to execute shell commands in response to file system events."""
+ from mcpstore.utils.watchdog.tricks import ShellCommandTrick
+
+ if not args.command:
+ args.command = None
+
+ observer_cls: ObserverType
+ if args.debug_force_polling:
+ from mcpstore.utils.watchdog.observers.polling import PollingObserver
+
+ observer_cls = PollingObserver
+ else:
+ from mcpstore.utils.watchdog.observers import Observer
+
+ observer_cls = Observer
+
+ patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
+ handler = ShellCommandTrick(
+ args.command,
+ patterns=patterns,
+ ignore_patterns=ignore_patterns,
+ ignore_directories=args.ignore_directories,
+ wait_for_process=args.wait_for_process,
+ drop_during_process=args.drop_during_process,
+ )
+ observer = observer_cls(timeout=args.timeout)
+ observe_with(observer, handler, args.directories, recursive=args.recursive)
+
+
+@command(
+ [
+ argument("command", help="Long-running command to run in a subprocess."),
+ argument(
+ "command_args",
+ metavar="arg",
+ nargs="*",
+ help="""
+ Command arguments.
+
+ Note: Use -- before the command arguments, otherwise watchmedo will
+ try to interpret them.
+ """,
+ ),
+ argument(
+ "-d",
+ "--directory",
+ dest="directories",
+ metavar="DIRECTORY",
+ action="append",
+ help="Directory to watch. Use another -d or --directory option for each directory.",
+ ),
+ argument(
+ "-p",
+ "--pattern",
+ "--patterns",
+ dest="patterns",
+ default="*",
+ help="Matches event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-i",
+ "--ignore-pattern",
+ "--ignore-patterns",
+ dest="ignore_patterns",
+ default="",
+ help="Ignores event paths with these patterns (separated by ;).",
+ ),
+ argument(
+ "-D",
+ "--ignore-directories",
+ dest="ignore_directories",
+ default=False,
+ action="store_true",
+ help="Ignores events for directories.",
+ ),
+ argument(
+ "-R",
+ "--recursive",
+ dest="recursive",
+ action="store_true",
+ help="Monitors the directories recursively.",
+ ),
+ argument(
+ "--interval",
+ "--timeout",
+ dest="timeout",
+ default=1.0,
+ type=float,
+ help="Use this as the polling interval/blocking timeout.",
+ ),
+ argument(
+ "--signal",
+ dest="signal",
+ default="SIGINT",
+ help="Stop the subprocess with this signal (default SIGINT).",
+ ),
+ argument("--debug-force-polling", action="store_true", help="[debug] Forces polling."),
+ argument(
+ "--kill-after",
+ dest="kill_after",
+ default=10.0,
+ type=float,
+ help="When stopping, kill the subprocess after the specified timeout in seconds (default 10.0).",
+ ),
+ argument(
+ "--debounce-interval",
+ dest="debounce_interval",
+ default=0.0,
+ type=float,
+ help="After a file change, Wait until the specified interval (in "
+ "seconds) passes with no file changes, and only then restart.",
+ ),
+ argument(
+ "--no-restart-on-command-exit",
+ dest="restart_on_command_exit",
+ default=True,
+ action="store_false",
+ help="Don't auto-restart the command after it exits.",
+ ),
+ ],
+)
+def auto_restart(args: Namespace) -> None:
+ """Command to start a long-running subprocess and restart it on matched events."""
+ observer_cls: ObserverType
+ if args.debug_force_polling:
+ from mcpstore.utils.watchdog.observers.polling import PollingObserver
+
+ observer_cls = PollingObserver
+ else:
+ from mcpstore.utils.watchdog.observers import Observer
+
+ observer_cls = Observer
+
+ import signal
+
+ from mcpstore.utils.watchdog.tricks import AutoRestartTrick
+
+ if not args.directories:
+ args.directories = ["."]
+
+ # Allow either signal name or number.
+ stop_signal = getattr(signal, args.signal) if args.signal.startswith("SIG") else int(args.signal)
+
+ # Handle termination signals by raising a semantic exception which will
+ # allow us to gracefully unwind and stop the observer
+ termination_signals = {signal.SIGTERM, signal.SIGINT}
+
+ if hasattr(signal, "SIGHUP"):
+ termination_signals.add(signal.SIGHUP)
+
+ def handler_termination_signal(_signum: signal._SIGNUM, _frame: object) -> None:
+ # Neuter all signals so that we don't attempt a double shutdown
+ for signum in termination_signals:
+ signal.signal(signum, signal.SIG_IGN)
+ raise WatchdogShutdownError
+
+ for signum in termination_signals:
+ signal.signal(signum, handler_termination_signal)
+
+ patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
+ command = [args.command]
+ command.extend(args.command_args)
+ handler = AutoRestartTrick(
+ command,
+ patterns=patterns,
+ ignore_patterns=ignore_patterns,
+ ignore_directories=args.ignore_directories,
+ stop_signal=stop_signal,
+ kill_after=args.kill_after,
+ debounce_interval_seconds=args.debounce_interval,
+ restart_on_command_exit=args.restart_on_command_exit,
+ )
+ handler.start()
+ observer = observer_cls(timeout=args.timeout)
+ try:
+ observe_with(observer, handler, args.directories, recursive=args.recursive)
+ except WatchdogShutdownError:
+ pass
+ finally:
+ handler.stop()
+
+
+class LogLevelError(Exception):
+ pass
+
+
+def _get_log_level_from_args(args: Namespace) -> str:
+ verbosity = sum(args.verbosity or [])
+ if verbosity < -1:
+ error = "-q/--quiet may be specified only once."
+ raise LogLevelError(error)
+ if verbosity > 2:
+ error = "-v/--verbose may be specified up to 2 times."
+ raise LogLevelError(error)
+ return ["ERROR", "WARNING", "INFO", "DEBUG"][1 + verbosity]
+
+
+def main() -> int:
+ """Entry-point function."""
+ args = cli.parse_args()
+ if args.top_command is None:
+ cli.print_help()
+ return 1
+
+ try:
+ log_level = _get_log_level_from_args(args)
+ except LogLevelError as exc:
+ print(f"Error: {exc.args[0]}", file=sys.stderr) # noqa:T201
+ command_parsers[args.top_command].print_help()
+ return 1
+ logging.getLogger("watchdog").setLevel(log_level)
+
+ try:
+ args.func(args)
+ except KeyboardInterrupt:
+ return 130
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/vue/.editorconfig b/vue/.editorconfig
new file mode 100644
index 00000000..cc4f21b7
--- /dev/null
+++ b/vue/.editorconfig
@@ -0,0 +1,18 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
+
diff --git a/vue/.eslintrc.cjs b/vue/.eslintrc.cjs
new file mode 100644
index 00000000..175ae247
--- /dev/null
+++ b/vue/.eslintrc.cjs
@@ -0,0 +1,33 @@
+/* eslint-env node */
+module.exports = {
+ root: true,
+ env: {
+ browser: true,
+ es2021: true,
+ node: true
+ },
+ extends: [
+ 'plugin:vue/vue3-recommended',
+ 'eslint:recommended'
+ ],
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module'
+ },
+ rules: {
+ // Vue 规则
+ 'vue/multi-word-component-names': 'off',
+ 'vue/no-v-html': 'warn',
+
+ // 代码质量
+ 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
+
+ // 代码风格(保持宽松)
+ 'semi': ['error', 'never'],
+ 'quotes': ['error', 'single', { avoidEscape: true }],
+ 'comma-dangle': ['error', 'never']
+ }
+}
+
diff --git a/vue/.prettierrc b/vue/.prettierrc
new file mode 100644
index 00000000..d53b0912
--- /dev/null
+++ b/vue/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "arrowParens": "avoid",
+ "endOfLine": "auto",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false
+}
+
diff --git a/vue/auto-imports.d.ts b/vue/auto-imports.d.ts
new file mode 100644
index 00000000..0e6d7d2e
--- /dev/null
+++ b/vue/auto-imports.d.ts
@@ -0,0 +1,88 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+ const EffectScope: typeof import('vue')['EffectScope']
+ const ElMessage: typeof import('element-plus/es')['ElMessage']
+ const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
+ const computed: typeof import('vue')['computed']
+ const createApp: typeof import('vue')['createApp']
+ const createPinia: typeof import('pinia')['createPinia']
+ const customRef: typeof import('vue')['customRef']
+ const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+ const defineComponent: typeof import('vue')['defineComponent']
+ const defineStore: typeof import('pinia')['defineStore']
+ const effectScope: typeof import('vue')['effectScope']
+ const getActivePinia: typeof import('pinia')['getActivePinia']
+ const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+ const getCurrentScope: typeof import('vue')['getCurrentScope']
+ const h: typeof import('vue')['h']
+ const inject: typeof import('vue')['inject']
+ const isProxy: typeof import('vue')['isProxy']
+ const isReactive: typeof import('vue')['isReactive']
+ const isReadonly: typeof import('vue')['isReadonly']
+ const isRef: typeof import('vue')['isRef']
+ const mapActions: typeof import('pinia')['mapActions']
+ const mapGetters: typeof import('pinia')['mapGetters']
+ const mapState: typeof import('pinia')['mapState']
+ const mapStores: typeof import('pinia')['mapStores']
+ const mapWritableState: typeof import('pinia')['mapWritableState']
+ const markRaw: typeof import('vue')['markRaw']
+ const nextTick: typeof import('vue')['nextTick']
+ const onActivated: typeof import('vue')['onActivated']
+ const onBeforeMount: typeof import('vue')['onBeforeMount']
+ const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
+ const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
+ const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+ const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+ const onDeactivated: typeof import('vue')['onDeactivated']
+ const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+ const onMounted: typeof import('vue')['onMounted']
+ const onRenderTracked: typeof import('vue')['onRenderTracked']
+ const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+ const onScopeDispose: typeof import('vue')['onScopeDispose']
+ const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+ const onUnmounted: typeof import('vue')['onUnmounted']
+ const onUpdated: typeof import('vue')['onUpdated']
+ const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
+ const provide: typeof import('vue')['provide']
+ const reactive: typeof import('vue')['reactive']
+ const readonly: typeof import('vue')['readonly']
+ const ref: typeof import('vue')['ref']
+ const resolveComponent: typeof import('vue')['resolveComponent']
+ const setActivePinia: typeof import('pinia')['setActivePinia']
+ const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
+ const shallowReactive: typeof import('vue')['shallowReactive']
+ const shallowReadonly: typeof import('vue')['shallowReadonly']
+ const shallowRef: typeof import('vue')['shallowRef']
+ const storeToRefs: typeof import('pinia')['storeToRefs']
+ const toRaw: typeof import('vue')['toRaw']
+ const toRef: typeof import('vue')['toRef']
+ const toRefs: typeof import('vue')['toRefs']
+ const toValue: typeof import('vue')['toValue']
+ const triggerRef: typeof import('vue')['triggerRef']
+ const unref: typeof import('vue')['unref']
+ const useAttrs: typeof import('vue')['useAttrs']
+ const useCssModule: typeof import('vue')['useCssModule']
+ const useCssVars: typeof import('vue')['useCssVars']
+ const useId: typeof import('vue')['useId']
+ const useLink: typeof import('vue-router')['useLink']
+ const useModel: typeof import('vue')['useModel']
+ const useRoute: typeof import('vue-router')['useRoute']
+ const useRouter: typeof import('vue-router')['useRouter']
+ const useSlots: typeof import('vue')['useSlots']
+ const useTemplateRef: typeof import('vue')['useTemplateRef']
+ const watch: typeof import('vue')['watch']
+ const watchEffect: typeof import('vue')['watchEffect']
+ const watchPostEffect: typeof import('vue')['watchPostEffect']
+ const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+ // @ts-ignore
+ export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+ import('vue')
+}
diff --git a/vue/components.d.ts b/vue/components.d.ts
new file mode 100644
index 00000000..f4c04963
--- /dev/null
+++ b/vue/components.d.ts
@@ -0,0 +1,76 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+ export interface GlobalComponents {
+ BatchOperations: typeof import('./src/components/BatchOperations.vue')['default']
+ ChartCard: typeof import('./src/components/common/ChartCard.vue')['default']
+ ElAlert: typeof import('element-plus/es')['ElAlert']
+ ElAside: typeof import('element-plus/es')['ElAside']
+ ElBadge: typeof import('element-plus/es')['ElBadge']
+ ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+ ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
+ ElButton: typeof import('element-plus/es')['ElButton']
+ ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+ ElCard: typeof import('element-plus/es')['ElCard']
+ ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+ ElCol: typeof import('element-plus/es')['ElCol']
+ ElCollapse: typeof import('element-plus/es')['ElCollapse']
+ ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+ ElContainer: typeof import('element-plus/es')['ElContainer']
+ ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+ ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
+ ElDialog: typeof import('element-plus/es')['ElDialog']
+ ElDivider: typeof import('element-plus/es')['ElDivider']
+ ElDropdown: typeof import('element-plus/es')['ElDropdown']
+ ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+ ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+ ElEmpty: typeof import('element-plus/es')['ElEmpty']
+ ElForm: typeof import('element-plus/es')['ElForm']
+ ElFormItem: typeof import('element-plus/es')['ElFormItem']
+ ElHeader: typeof import('element-plus/es')['ElHeader']
+ ElIcon: typeof import('element-plus/es')['ElIcon']
+ ElInput: typeof import('element-plus/es')['ElInput']
+ ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+ ElMain: typeof import('element-plus/es')['ElMain']
+ ElMenu: typeof import('element-plus/es')['ElMenu']
+ ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+ ElOption: typeof import('element-plus/es')['ElOption']
+ ElPagination: typeof import('element-plus/es')['ElPagination']
+ ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
+ ElRadio: typeof import('element-plus/es')['ElRadio']
+ ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
+ ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+ ElRow: typeof import('element-plus/es')['ElRow']
+ ElSelect: typeof import('element-plus/es')['ElSelect']
+ ElStatistic: typeof import('element-plus/es')['ElStatistic']
+ ElSwitch: typeof import('element-plus/es')['ElSwitch']
+ ElTable: typeof import('element-plus/es')['ElTable']
+ ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+ ElTabPane: typeof import('element-plus/es')['ElTabPane']
+ ElTabs: typeof import('element-plus/es')['ElTabs']
+ ElTag: typeof import('element-plus/es')['ElTag']
+ ElTooltip: typeof import('element-plus/es')['ElTooltip']
+ ElUpload: typeof import('element-plus/es')['ElUpload']
+ ErrorState: typeof import('./src/components/common/ErrorState.vue')['default']
+ JsonEditor: typeof import('./src/components/config/JsonEditor.vue')['default']
+ PageLoading: typeof import('./src/components/common/PageLoading.vue')['default']
+ PerformanceChart: typeof import('./src/components/charts/PerformanceChart.vue')['default']
+ RouterLink: typeof import('vue-router')['RouterLink']
+ RouterView: typeof import('vue-router')['RouterView']
+ ServiceDetailsTable: typeof import('./src/components/ServiceDetailsTable.vue')['default']
+ ServiceForm: typeof import('./src/components/agents/ServiceForm.vue')['default']
+ ServiceLifecycleStatus: typeof import('./src/components/ServiceLifecycleStatus.vue')['default']
+ ServiceStatusSummary: typeof import('./src/components/ServiceStatusSummary.vue')['default']
+ StatCard: typeof import('./src/components/common/StatCard.vue')['default']
+ StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default']
+ TabsView: typeof import('./src/components/layout/TabsView.vue')['default']
+ }
+ export interface ComponentCustomProperties {
+ vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+ }
+}
diff --git a/vue/config/development.json b/vue/config/development.json
new file mode 100644
index 00000000..cbddf906
--- /dev/null
+++ b/vue/config/development.json
@@ -0,0 +1,57 @@
+{
+ "api": {
+ "baseURL": "http://localhost:18200",
+ "timeout": 30000,
+ "retries": 3
+ },
+ "websocket": {
+ "url": "ws://localhost:18200/ws",
+ "reconnectInterval": 5000,
+ "maxReconnectAttempts": 10
+ },
+ "auth": {
+ "type": "basic",
+ "loginEndpoint": "/auth/login",
+ "tokenStorage": "localStorage",
+ "refreshThreshold": 300000
+ },
+ "ui": {
+ "theme": "light",
+ "language": "zh-CN",
+ "timezone": "Asia/Shanghai",
+ "dateFormat": "YYYY-MM-DD HH:mm:ss",
+ "itemsPerPage": 20,
+ "enableAnimations": true,
+ "showDebugInfo": true
+ },
+ "features": {
+ "realtimeUpdates": true,
+ "darkMode": false,
+ "compactMode": false,
+ "showHiddenServices": true,
+ "enableExperimental": true
+ },
+ "monitoring": {
+ "enabled": true,
+ "trackErrors": true,
+ "trackPerformance": true,
+ "sentryDSN": "",
+ "logLevel": "debug"
+ },
+ "cache": {
+ "services": {
+ "ttl": 60000,
+ "maxEntries": 100
+ },
+ "tools": {
+ "ttl": 30000,
+ "maxEntries": 500
+ }
+ },
+ "devtools": {
+ "enabled": true,
+ "mockData": false,
+ "hotReload": true,
+ "vueDevtools": true
+ }
+}
\ No newline at end of file
diff --git a/vue/config/production.json b/vue/config/production.json
new file mode 100644
index 00000000..1036426f
--- /dev/null
+++ b/vue/config/production.json
@@ -0,0 +1,78 @@
+{
+ "api": {
+ "baseURL": "https://api.mcpstore.wiki",
+ "timeout": 10000,
+ "retries": 2
+ },
+ "websocket": {
+ "url": "wss://api.mcpstore.wiki/ws",
+ "reconnectInterval": 3000,
+ "maxReconnectAttempts": 5
+ },
+ "auth": {
+ "type": "oauth",
+ "loginEndpoint": "/oauth/authorize",
+ "tokenStorage": "secureCookie",
+ "refreshThreshold": 600000
+ },
+ "ui": {
+ "theme": "auto",
+ "language": "zh-CN",
+ "timezone": "Asia/Shanghai",
+ "dateFormat": "YYYY-MM-DD HH:mm:ss",
+ "itemsPerPage": 50,
+ "enableAnimations": false,
+ "showDebugInfo": false
+ },
+ "features": {
+ "realtimeUpdates": true,
+ "darkMode": true,
+ "compactMode": false,
+ "showHiddenServices": false,
+ "enableExperimental": false
+ },
+ "monitoring": {
+ "enabled": true,
+ "trackErrors": true,
+ "trackPerformance": true,
+ "sentryDSN": "https://your-sentry-dsn@sentry.io/project-id",
+ "logLevel": "warn"
+ },
+ "cache": {
+ "services": {
+ "ttl": 300000,
+ "maxEntries": 1000
+ },
+ "tools": {
+ "ttl": 60000,
+ "maxEntries": 2000
+ }
+ },
+ "security": {
+ "contentSecurityPolicy": {
+ "enabled": true,
+ "directives": {
+ "default-src": ["'self'"],
+ "script-src": ["'self'", "'unsafe-inline'", "https://cdn.trusted.com"],
+ "style-src": ["'self'", "'unsafe-inline'"],
+ "img-src": ["'self'", "data:", "https:"],
+ "connect-src": ["'self'", "https://api.mcpstore.wiki", "wss://api.mcpstore.wiki"]
+ }
+ },
+ "XSSProtection": true,
+ "clickjacking": {
+ "enabled": true,
+ "mode": "DENY"
+ }
+ },
+ "performance": {
+ "lazyLoad": true,
+ "codeSplitting": true,
+ "gzip": true,
+ "brotli": true,
+ "cdn": {
+ "enabled": true,
+ "baseURL": "https://cdn.mcpstore.wiki"
+ }
+ }
+}
\ No newline at end of file
diff --git a/vue/index.html b/vue/index.html
new file mode 100644
index 00000000..018e913e
--- /dev/null
+++ b/vue/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ MCPStore 管理面板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/package.json b/vue/package.json
new file mode 100644
index 00000000..0b5c0f01
--- /dev/null
+++ b/vue/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "mcpstore-vue-frontend",
+ "version": "0.6.0",
+ "description": "MCPStore Vue.js Frontend - 数据空间隔离版前端管理界面",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 5177 --mode development",
+ "dev:local": "vite --port 5177 --host localhost --mode local",
+ "dev:domain": "vite --port 5177 --host 0.0.0.0 --mode domain",
+ "build": "vite build --mode production",
+ "build:prod": "vite build --mode production",
+ "build:domain": "vite build --mode domain",
+ "preview": "vite preview --port 5177 --host 0.0.0.0 --mode production",
+ "serve:prod": "vite preview --port 5177 --host 0.0.0.0 --mode production",
+ "deploy": "chmod +x deploy.sh && ./deploy.sh",
+ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
+ "format": "prettier --write src/",
+ "analyze": "vite build --mode analyze"
+ },
+ "dependencies": {
+ "@element-plus/icons-vue": "^2.3.1",
+ "@iarna/toml": "^2.2.5",
+ "axios": "^1.6.0",
+ "dayjs": "^1.11.10",
+ "echarts": "^5.6.0",
+ "element-plus": "^2.4.4",
+ "lodash-es": "^4.17.21",
+ "nprogress": "^0.2.0",
+ "pinia": "^2.1.7",
+ "vue": "^3.4.0",
+ "vue-echarts": "^6.6.1",
+ "vue-router": "^4.2.5"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^4.5.2",
+ "eslint": "^8.56.0",
+ "eslint-plugin-vue": "^9.19.2",
+ "prettier": "^3.1.1",
+ "sass": "^1.69.5",
+ "unplugin-auto-import": "^0.17.2",
+ "unplugin-vue-components": "^0.26.0",
+ "vite": "^5.0.8"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "keywords": [
+ "vue",
+ "mcp",
+ "mcpstore",
+ "frontend",
+ "management",
+ "dashboard"
+ ],
+ "author": "MCPStore Team",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/your-repo/mcpstore"
+ },
+ "bugs": {
+ "url": "https://github.com/your-repo/mcpstore/issues"
+ },
+ "homepage": "https://github.com/your-repo/mcpstore#readme"
+}
diff --git a/vue/src/App.vue b/vue/src/App.vue
new file mode 100644
index 00000000..e63bc146
--- /dev/null
+++ b/vue/src/App.vue
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/api/agent.js b/vue/src/api/agent.js
new file mode 100644
index 00000000..4653666e
--- /dev/null
+++ b/vue/src/api/agent.js
@@ -0,0 +1,216 @@
+import { API_ENDPOINTS } from './config'
+import { formatApiPath, extractResponseData } from './utils'
+import { apiRequest } from './request'
+
+/**
+ * Agent 级别 API 服务
+ * 对应 MCPStore API v1.0.0 的 Agent 级别端点
+ */
+export const agentApi = {
+ /**
+ * Agent 管理 - 使用新接口 /for_store/list_agents
+ */
+ getAgentsList: () => apiRequest.get(API_ENDPOINTS.STORE.LIST_AGENTS),
+
+ /**
+ * 服务管理
+ */
+ addService: (agentId, payload) => {
+ // 后端不再支持 wait,且必须提供有效配置
+ if (!payload || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
+ throw new Error('agent.addService: 必须提供服务配置(后端不再支持空参数)')
+ }
+ if ('wait' in (payload || {}) || ('options' in (payload || {}) && payload.options && 'wait' in payload.options)) {
+ throw new Error('agent.addService: 后端不再支持 wait 参数,请移除后重试')
+ }
+ return apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.ADD_SERVICE, { agent_id: agentId }),
+ payload
+ )
+ },
+
+ listServices: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.LIST_SERVICES, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data, [])),
+
+ // 兼容旧调用:返回带 success 的包装结构,避免调用端找不到方法
+ getAgentServices: async (agentId) => {
+ const res = await apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.LIST_SERVICES, { agent_id: agentId })
+ )
+ const services = extractResponseData(res.data, [])
+ return { data: { success: true, data: services } }
+ },
+
+ initService: (agentId, identifier) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.INIT_SERVICE, { agent_id: agentId }),
+ identifier
+ ),
+
+ deleteService: (agentId, serviceName) => apiRequest.delete(
+ formatApiPath(API_ENDPOINTS.AGENT.DELETE_SERVICE, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ),
+
+ updateService: (agentId, serviceName, config) => apiRequest.put(
+ formatApiPath(API_ENDPOINTS.AGENT.UPDATE_SERVICE, {
+ agent_id: agentId,
+ service_name: serviceName
+ }),
+ config
+ ),
+
+ waitService: (agentId, params) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.WAIT_SERVICE, { agent_id: agentId }),
+ params
+ ),
+
+ restartService: (agentId, serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.RESTART_SERVICE, { agent_id: agentId }),
+ { service_name: serviceName }
+ ),
+
+ /**
+ * 工具管理
+ */
+ listTools: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.LIST_TOOLS, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data, [])),
+
+ // 兼容旧调用:返回带 success 的包装结构,避免调用端找不到方法
+ getAgentTools: async (agentId) => {
+ const res = await apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.LIST_TOOLS, { agent_id: agentId })
+ )
+ const tools = extractResponseData(res.data, [])
+ return { data: { success: true, data: tools } }
+ },
+
+ callTool: (agentId, toolName, args, serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.CALL_TOOL, { agent_id: agentId }),
+ {
+ tool_name: toolName,
+ args: args || {},
+ service_name: serviceName
+ }
+ ),
+
+ // 向后兼容
+ useTool: (agentId, toolName, args, serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.USE_TOOL, { agent_id: agentId }),
+ {
+ tool_name: toolName,
+ args: args || {},
+ service_name: serviceName
+ }
+ ),
+
+ /**
+ * 服务详情
+ */
+ getServiceInfo: (agentId, serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.SERVICE_INFO, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ).then(res => extractResponseData(res.data)),
+
+ getServiceStatus: (agentId, serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.SERVICE_STATUS, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ).then(res => extractResponseData(res.data)),
+
+ checkServiceHealth: (agentId, serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.SERVICE_HEALTH, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ).then(res => extractResponseData(res.data)),
+
+ getServiceHealthDetails: (agentId, serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.SERVICE_HEALTH_DETAILS, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ).then(res => extractResponseData(res.data)),
+
+ /**
+ * 健康检查
+ */
+ checkServices: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.CHECK_SERVICES, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data)),
+
+ getHealth: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.HEALTH, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data)),
+
+ /**
+ * 配置管理
+ */
+ showConfig: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.SHOW_CONFIG, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data)),
+
+ showMcpConfig: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.SHOW_MCP_CONFIG, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data)),
+
+ getJsonConfig: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.GET_JSON_CONFIG, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data)),
+
+ updateConfig: (agentId, clientIdOrServiceName, config) => apiRequest.put(
+ formatApiPath(API_ENDPOINTS.AGENT.UPDATE_CONFIG, {
+ agent_id: agentId,
+ client_id_or_service_name: clientIdOrServiceName
+ }),
+ config
+ ),
+
+ deleteConfig: (agentId, clientIdOrServiceName) => apiRequest.delete(
+ formatApiPath(API_ENDPOINTS.AGENT.DELETE_CONFIG, {
+ agent_id: agentId,
+ client_id_or_service_name: clientIdOrServiceName
+ })
+ ),
+
+ resetConfig: (agentId) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.RESET_CONFIG, { agent_id: agentId })
+ ),
+
+ resetClientServices: (agentId) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.RESET_CLIENT_SERVICES, { agent_id: agentId })
+ ),
+
+ resetAgentClients: (agentId) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.AGENT.RESET_AGENT_CLIENTS, { agent_id: agentId })
+ ),
+
+ /**
+ * 统计信息 - Agent 级别使用 list_services 获取(后端没有 get_stats 接口)
+ */
+ getStats: async (agentId) => {
+ try {
+ const res = await apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.LIST_SERVICES, { agent_id: agentId })
+ )
+ const data = extractResponseData(res.data, { services: [] })
+ return {
+ services_count: Array.isArray(data?.services) ? data.services.length : 0,
+ status: 'active'
+ }
+ } catch {
+ return { services_count: 0, status: 'unknown' }
+ }
+ },
+
+ getToolRecords: (agentId, limit = 50) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.AGENT.TOOL_RECORDS, { agent_id: agentId }),
+ { params: { limit } }
+ ).then(res => extractResponseData(res.data))
+}
diff --git a/vue/src/api/cache.js b/vue/src/api/cache.js
new file mode 100644
index 00000000..115795aa
--- /dev/null
+++ b/vue/src/api/cache.js
@@ -0,0 +1,45 @@
+import { apiRequest } from './request'
+
+// Base path for global store cache operations
+const BASE_PATH = '/for_store/cache'
+
+export const cacheApi = {
+ /**
+ * Get cache statistics and configuration
+ * @returns {Promise}
+ */
+ inspect: () => apiRequest.get(`${BASE_PATH}/inspect`),
+
+ /**
+ * Get entity cache items
+ * @param {Object} params - Query parameters
+ * @param {string} [params.type] - Comma separated types (e.g. 'services,tools')
+ * @param {string} [params.key] - Filter by specific key
+ * @returns {Promise}
+ */
+ getEntities: (params = {}) => apiRequest.get(`${BASE_PATH}/entities`, { params }),
+
+ /**
+ * Get relation cache items
+ * @param {Object} params - Query parameters
+ * @param {string} [params.type] - Comma separated types
+ * @param {string} [params.key] - Filter by specific key
+ * @returns {Promise}
+ */
+ getRelations: (params = {}) => apiRequest.get(`${BASE_PATH}/relations`, { params }),
+
+ /**
+ * Get state cache items
+ * @param {Object} params - Query parameters
+ * @param {string} [params.type] - Comma separated types
+ * @param {string} [params.key] - Filter by specific key
+ * @returns {Promise}
+ */
+ getStates: (params = {}) => apiRequest.get(`${BASE_PATH}/states`, { params }),
+
+ /**
+ * Dump full cache snapshot
+ * @returns {Promise}
+ */
+ dump: () => apiRequest.get(`${BASE_PATH}/dump`)
+}
diff --git a/vue/src/api/config.js b/vue/src/api/config.js
new file mode 100644
index 00000000..41e550b2
--- /dev/null
+++ b/vue/src/api/config.js
@@ -0,0 +1,159 @@
+/**
+ * MCPStore API v1.0.0 配置
+ * 统一的 API 配置和常量定义
+ */
+
+// API 版本
+export const API_VERSION = '1.0.0'
+export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
+export const API_TIMEOUT_MS = parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000
+
+// API 端点路径
+export const API_ENDPOINTS = {
+ // Store 级别 API
+ STORE: {
+ SYNC_SERVICES: '/for_store/sync_services',
+ SYNC_STATUS: '/for_store/sync_status',
+ LIST_SERVICES: '/for_store/list_services',
+ ADD_SERVICE: '/for_store/add_service',
+ INIT_SERVICE: '/for_store/init_service',
+ DELETE_SERVICE: '/for_store/delete_service/{service_name}',
+ LIST_TOOLS: '/for_store/list_tools',
+ CALL_TOOL: '/for_store/call_tool',
+ TOOL_INFO: '/for_store/tool_info/{tool_name}',
+ SERVICE_INFO: '/for_store/service_info/{service_name}',
+ SERVICE_STATUS: '/for_store/service_status/{service_name}',
+ SERVICE_HEALTH: '/for_store/service_health/{service_name}',
+ CHECK_SERVICES: '/for_store/check_services',
+ HEALTH: '/for_store/health',
+ SHOW_CONFIG: '/for_store/show_config',
+ SHOW_MCPJSON: '/for_store/show_mcpjson',
+ UPDATE_CONFIG: '/for_store/update_config/{client_id_or_service_name}',
+ RESET_CONFIG: '/for_store/reset_config',
+ RESET_MCPJSON: '/for_store/reset_mcpjson',
+
+ TOOL_RECORDS: '/for_store/tool_records',
+ SYSTEM_RESOURCES: '/for_store/system_resources',
+ NETWORK_CHECK: '/for_store/network_check',
+ LIST_AGENTS: '/for_store/list_agents',
+ LIST_SERVICES_BY_AGENT: '/for_store/list_services_by_agent',
+ RESTART_SERVICE: '/for_store/restart_service',
+ UPDATE_SERVICE: '/for_store/update_service/{service_name}',
+ BATCH_UPDATE_SERVICES: '/for_store/batch_update_services',
+ BATCH_DELETE_SERVICES: '/for_store/batch_delete_services',
+ BATCH_RESTART_SERVICES: '/for_store/batch_restart_services'
+ },
+
+ // Agent 级别 API
+ AGENT: {
+ ADD_SERVICE: '/for_agent/{agent_id}/add_service',
+ LIST_SERVICES: '/for_agent/{agent_id}/list_services',
+ INIT_SERVICE: '/for_agent/{agent_id}/init_service',
+ DELETE_SERVICE: '/for_agent/{agent_id}/delete_service/{service_name}',
+ UPDATE_SERVICE: '/for_agent/{agent_id}/update_service/{service_name}',
+ LIST_TOOLS: '/for_agent/{agent_id}/list_tools',
+ CALL_TOOL: '/for_agent/{agent_id}/call_tool',
+ WAIT_SERVICE: '/for_agent/{agent_id}/wait_service',
+ RESTART_SERVICE: '/for_agent/{agent_id}/restart_service',
+ SERVICE_INFO: '/for_agent/{agent_id}/service_info/{service_name}',
+ SERVICE_STATUS: '/for_agent/{agent_id}/service_status/{service_name}',
+ SERVICE_HEALTH: '/for_agent/{agent_id}/service_health/{service_name}',
+ SERVICE_HEALTH_DETAILS: '/for_agent/{agent_id}/service_health_details/{service_name}',
+ CHECK_SERVICES: '/for_agent/{agent_id}/check_services',
+ HEALTH: '/for_agent/{agent_id}/health',
+ SHOW_CONFIG: '/for_agent/{agent_id}/show_config',
+ SHOW_MCP_CONFIG: '/for_agent/{agent_id}/show_mcpconfig',
+ GET_JSON_CONFIG: '/for_agent/{agent_id}/get_json_config',
+ UPDATE_CONFIG: '/for_agent/{agent_id}/update_config/{client_id_or_service_name}',
+ DELETE_CONFIG: '/for_agent/{agent_id}/delete_config/{client_id_or_service_name}',
+ RESET_CONFIG: '/for_agent/{agent_id}/reset_config',
+ RESET_CLIENT_SERVICES: '/for_agent/{agent_id}/reset_client_services_file',
+ RESET_AGENT_CLIENTS: '/for_agent/{agent_id}/reset_agent_clients_file',
+ TOOL_RECORDS: '/for_agent/{agent_id}/tool_records',
+ USE_TOOL: '/for_agent/{agent_id}/use_tool' // 向后兼容
+ },
+
+ // 监控和生命周期 API
+ MONITORING: {
+ AGENTS_SUMMARY: '/agents_summary',
+ LIFECYCLE_CONFIG: '/lifecycle/config',
+ HEALTH_SUMMARY: '/health/summary',
+ HEALTH_SERVICE: '/health/service/{service_name}',
+ HEALTH_CHECK: '/health/check/{service_name}',
+ TOOLS_REFRESH: '/tools/refresh',
+ TOOLS_REFRESH_SERVICE: '/tools/refresh/{service_name}',
+ TOOLS_UPDATE_STATUS: '/tools/update_status',
+ CONTENT_SNAPSHOT: '/content/snapshot/{service_name}',
+ CONTENT_SNAPSHOTS: '/content/snapshots',
+ LIFECYCLE_DISCONNECT: '/lifecycle/disconnect/{service_name}',
+ ALERTS: '/monitoring/alerts',
+ PERFORMANCE: '/monitoring/performance',
+ USAGE_STATS: '/monitoring/usage_stats'
+ },
+
+ // 数据空间管理 API
+ DATA_SPACE: {
+ INFO: '/data_space/info',
+ WORKSPACE_LIST: '/workspace/list',
+ WORKSPACE_CREATE: '/workspace/create',
+ WORKSPACE_SWITCH: '/workspace/switch',
+ WORKSPACE_CURRENT: '/workspace/current',
+ WORKSPACE_DELETE: '/workspace/{workspace_name}'
+ },
+
+ // LangChain 集成 API
+ LANGCHAIN: {
+ STORE_TOOLS: '/for_store/langchain_tools',
+ STORE_SERVICE_TOOLS: '/for_store/langchain_tools/{service_name}',
+ STORE_TOOL_EXECUTE: '/for_store/langchain_tool_execute',
+ STORE_TOOL_INFO: '/for_store/langchain_tool_info/{tool_name}',
+ AGENT_TOOLS: '/for_agent/{agent_id}/langchain_tools',
+ AGENT_SERVICE_TOOLS: '/for_agent/{agent_id}/langchain_tools/{service_name}',
+ AGENT_TOOL_EXECUTE: '/for_agent/{agent_id}/langchain_tool_execute'
+ }
+}
+
+// 服务生命周期状态
+export const SERVICE_LIFECYCLE_STATES = {
+ INITIALIZING: 'initializing',
+ HEALTHY: 'healthy',
+ WARNING: 'warning',
+ RECONNECTING: 'reconnecting',
+ UNREACHABLE: 'unreachable',
+ DISCONNECTING: 'disconnecting',
+ DISCONNECTED: 'disconnected'
+}
+
+// API 响应状态码
+export const API_STATUS_CODES = {
+ SUCCESS: 200,
+ CREATED: 201,
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ INTERNAL_ERROR: 500
+}
+
+// 错误类型
+export const ERROR_TYPES = {
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
+ NOT_FOUND: 'NOT_FOUND',
+ UNAUTHORIZED: 'UNAUTHORIZED',
+ FORBIDDEN: 'FORBIDDEN',
+ SERVICE_NOT_FOUND: 'SERVICE_NOT_FOUND',
+ SERVICE_OPERATION_FAILED: 'SERVICE_OPERATION_FAILED',
+ AGENT_NOT_FOUND: 'AGENT_NOT_FOUND',
+ TOOL_NOT_FOUND: 'TOOL_NOT_FOUND',
+ CONFIG_ERROR: 'CONFIG_ERROR'
+}
+
+// 工具执行状态
+export const TOOL_EXECUTION_STATUS = {
+ PENDING: 'pending',
+ RUNNING: 'running',
+ SUCCESS: 'success',
+ FAILED: 'failed',
+ TIMEOUT: 'timeout'
+}
diff --git a/vue/src/api/dataSpace.js b/vue/src/api/dataSpace.js
new file mode 100644
index 00000000..c0f8423b
--- /dev/null
+++ b/vue/src/api/dataSpace.js
@@ -0,0 +1,32 @@
+import { API_ENDPOINTS } from './config'
+import { formatApiPath, extractResponseData } from './utils'
+import { apiRequest } from './request'
+
+/**
+ * 数据空间管理 API 服务
+ * 对应 MCPStore API v1.0.0 的数据空间管理端点
+ */
+export const dataSpaceApi = {
+ /**
+ * 数据空间信息
+ */
+ getDataSpaceInfo: () => apiRequest.get(API_ENDPOINTS.DATA_SPACE.INFO)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 工作空间管理
+ */
+ listWorkspaces: () => apiRequest.get(API_ENDPOINTS.DATA_SPACE.WORKSPACE_LIST)
+ .then(res => extractResponseData(res.data, [])),
+
+ createWorkspace: (data) => apiRequest.post(API_ENDPOINTS.DATA_SPACE.WORKSPACE_CREATE, data),
+
+ switchWorkspace: (data) => apiRequest.post(API_ENDPOINTS.DATA_SPACE.WORKSPACE_SWITCH, data),
+
+ getCurrentWorkspace: () => apiRequest.get(API_ENDPOINTS.DATA_SPACE.WORKSPACE_CURRENT)
+ .then(res => extractResponseData(res.data)),
+
+ deleteWorkspace: (workspaceName) => apiRequest.delete(
+ formatApiPath(API_ENDPOINTS.DATA_SPACE.WORKSPACE_DELETE, { workspace_name: workspaceName })
+ )
+}
\ No newline at end of file
diff --git a/vue/src/api/index.js b/vue/src/api/index.js
new file mode 100644
index 00000000..05625d4f
--- /dev/null
+++ b/vue/src/api/index.js
@@ -0,0 +1,24 @@
+// 导出所有 API 模块
+export * from './config'
+export * from './utils'
+export * from './request'
+export * from './store'
+export * from './agent'
+export * from './monitoring'
+export * from './dataSpace'
+export * from './langChain'
+
+// 便捷的统一导出
+import { storeApi } from './store'
+import { agentApi } from './agent'
+import { monitoringApi } from './monitoring'
+import { dataSpaceApi } from './dataSpace'
+import { langChainApi } from './langChain'
+
+export const api = {
+ store: storeApi,
+ agent: agentApi,
+ monitoring: monitoringApi,
+ dataSpace: dataSpaceApi,
+ langChain: langChainApi
+}
\ No newline at end of file
diff --git a/vue/src/api/langChain.js b/vue/src/api/langChain.js
new file mode 100644
index 00000000..ca362360
--- /dev/null
+++ b/vue/src/api/langChain.js
@@ -0,0 +1,52 @@
+import { API_ENDPOINTS } from './config'
+import { formatApiPath, extractResponseData } from './utils'
+import { apiRequest } from './request'
+
+/**
+ * LangChain 集成 API 服务
+ * 对应 MCPStore API v1.0.0 的 LangChain 集成端点
+ */
+export const langChainApi = {
+ /**
+ * Store 级别 LangChain 工具
+ */
+ getStoreTools: () => apiRequest.get(API_ENDPOINTS.LANGCHAIN.STORE_TOOLS)
+ .then(res => extractResponseData(res.data, [])),
+
+ getStoreServiceTools: (serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.LANGCHAIN.STORE_SERVICE_TOOLS, { service_name: serviceName })
+ ).then(res => extractResponseData(res.data, [])),
+
+ executeStoreTool: (toolName, args, kwargs) => apiRequest.post(API_ENDPOINTS.LANGCHAIN.STORE_TOOL_EXECUTE, {
+ tool_name: toolName,
+ args: args || [],
+ kwargs: kwargs || {}
+ }),
+
+ getStoreToolInfo: (toolName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.LANGCHAIN.STORE_TOOL_INFO, { tool_name: toolName })
+ ).then(res => extractResponseData(res.data)),
+
+ /**
+ * Agent 级别 LangChain 工具
+ */
+ getAgentTools: (agentId) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.LANGCHAIN.AGENT_TOOLS, { agent_id: agentId })
+ ).then(res => extractResponseData(res.data, [])),
+
+ getAgentServiceTools: (agentId, serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.LANGCHAIN.AGENT_SERVICE_TOOLS, {
+ agent_id: agentId,
+ service_name: serviceName
+ })
+ ).then(res => extractResponseData(res.data, [])),
+
+ executeAgentTool: (agentId, toolName, args, kwargs) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.LANGCHAIN.AGENT_TOOL_EXECUTE, { agent_id: agentId }),
+ {
+ tool_name: toolName,
+ args: args || [],
+ kwargs: kwargs || {}
+ }
+ )
+}
\ No newline at end of file
diff --git a/vue/src/api/monitoring.js b/vue/src/api/monitoring.js
new file mode 100644
index 00000000..07bc25f0
--- /dev/null
+++ b/vue/src/api/monitoring.js
@@ -0,0 +1,109 @@
+import { API_ENDPOINTS } from './config'
+import { formatApiPath, extractResponseData } from './utils'
+import { apiRequest } from './request'
+
+/**
+ * 监控和生命周期 API 服务
+ * 对应 MCPStore API v1.0.0 的监控端点
+ */
+export const monitoringApi = {
+ /**
+ * Agent 统计
+ */
+ getAgentsSummary: () => apiRequest.get(API_ENDPOINTS.MONITORING.AGENTS_SUMMARY)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 生命周期配置
+ */
+ getLifecycleConfig: () => apiRequest.get(API_ENDPOINTS.MONITORING.LIFECYCLE_CONFIG)
+ .then(res => extractResponseData(res.data)),
+
+ updateLifecycleConfig: (config) => apiRequest.post(API_ENDPOINTS.MONITORING.LIFECYCLE_CONFIG, config),
+
+ /**
+ * 健康状态汇总
+ */
+ getHealthSummary: () => apiRequest.get(API_ENDPOINTS.MONITORING.HEALTH_SUMMARY)
+ .then(res => extractResponseData(res.data)),
+
+ getServiceHealth: (serviceName, agentId = null) => {
+ const params = agentId ? { agent_id: agentId } : {}
+ return apiRequest.get(
+ formatApiPath(API_ENDPOINTS.MONITORING.HEALTH_SERVICE, { service_name: serviceName }),
+ { params }
+ ).then(res => extractResponseData(res.data))
+ },
+
+ triggerHealthCheck: (serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.MONITORING.HEALTH_CHECK, { service_name: serviceName })
+ ).then(res => extractResponseData(res.data)),
+
+ /**
+ * 内容管理
+ */
+ refreshAllTools: () => apiRequest.post(API_ENDPOINTS.MONITORING.TOOLS_REFRESH)
+ .then(res => extractResponseData(res.data)),
+
+ refreshServiceTools: (serviceName, agentId = null) => {
+ const params = agentId ? { agent_id: agentId } : {}
+ return apiRequest.post(
+ formatApiPath(API_ENDPOINTS.MONITORING.TOOLS_REFRESH_SERVICE, { service_name: serviceName }),
+ params
+ ).then(res => extractResponseData(res.data))
+ },
+
+ getToolsUpdateStatus: () => apiRequest.get(API_ENDPOINTS.MONITORING.TOOLS_UPDATE_STATUS)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 系统资源(为完整性提供,当前 store 走的是 storeApi)
+ */
+ getSystemResources: () => apiRequest.get(API_ENDPOINTS.STORE.SYSTEM_RESOURCES)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 内容快照
+ */
+ getServiceContentSnapshot: (serviceName, agentId = null) => {
+ const params = agentId ? { agent_id: agentId } : {}
+ return apiRequest.get(
+ formatApiPath(API_ENDPOINTS.MONITORING.CONTENT_SNAPSHOT, { service_name: serviceName }),
+ { params }
+ ).then(res => extractResponseData(res.data))
+ },
+
+ getAllContentSnapshots: () => apiRequest.get(API_ENDPOINTS.MONITORING.CONTENT_SNAPSHOTS)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 生命周期管理
+ */
+ gracefulDisconnect: (serviceName, agentId = null, reason = 'user_requested') => {
+ const params = agentId ? { agent_id: agentId } : {}
+ return apiRequest.post(
+ formatApiPath(API_ENDPOINTS.MONITORING.LIFECYCLE_DISCONNECT, { service_name: serviceName }),
+ { reason, ...params }
+ ).then(res => extractResponseData(res.data))
+ },
+
+ /**
+ * 告警管理
+ */
+ addAlert: (alert) => apiRequest.post(API_ENDPOINTS.MONITORING.ALERTS, alert),
+
+ getAlerts: (limit = 50) => apiRequest.get(API_ENDPOINTS.MONITORING.ALERTS, {
+ params: { limit }
+ }).then(res => extractResponseData(res.data, [])),
+
+ clearAlerts: () => apiRequest.delete(API_ENDPOINTS.MONITORING.ALERTS),
+
+ /**
+ * 性能监控
+ */
+ getPerformanceMetrics: () => apiRequest.get(API_ENDPOINTS.MONITORING.PERFORMANCE)
+ .then(res => extractResponseData(res.data)),
+
+ getUsageStatistics: () => apiRequest.get(API_ENDPOINTS.MONITORING.USAGE_STATS)
+ .then(res => extractResponseData(res.data))
+}
diff --git a/vue/src/api/request.js b/vue/src/api/request.js
new file mode 100644
index 00000000..78b688c4
--- /dev/null
+++ b/vue/src/api/request.js
@@ -0,0 +1,330 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+import { API_BASE_URL, API_TIMEOUT_MS, API_VERSION } from './config'
+import { handleApiError } from './utils'
+import { STORAGE_KEYS } from '@/utils/constants'
+
+// 创建axios实例
+const request = axios.create({
+ baseURL: API_BASE_URL,
+ timeout: API_TIMEOUT_MS,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Version': API_VERSION
+ }
+})
+
+// 请求拦截器
+request.interceptors.request.use(
+ (config) => {
+ // 添加时间戳防止缓存(仅GET请求)
+ if (config.method === 'get') {
+ config.params = {
+ ...config.params,
+ _t: Date.now()
+ }
+ }
+
+ // 添加认证头(如果有token)
+ const token = localStorage.getItem(STORAGE_KEYS.TOKEN)
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+
+ // 开发环境或启用日志时显示详细日志
+ if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_CONSOLE_LOG !== 'false') {
+ console.log('🚀 [REQUEST]:', {
+ method: config.method?.toUpperCase(),
+ url: config.url,
+ params: config.params,
+ data: config.data
+ })
+ }
+
+ return config
+ },
+ (error) => {
+ console.error('❌ [REQUEST ERROR]:', error)
+ return Promise.reject(handleApiError(error, 'Request'))
+ }
+)
+
+// 响应拦截器
+request.interceptors.response.use(
+ (response) => {
+ const { data } = response
+
+ // 开发环境或启用日志时显示详细日志
+ if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_CONSOLE_LOG !== 'false') {
+ console.log('✅ [RESPONSE]:', {
+ status: response.status,
+ url: response.config.url,
+ data: data
+ })
+ }
+
+ // 统一的响应格式验证
+ if (data && typeof data === 'object') {
+ // 检查API响应格式
+ if ('success' in data && !data.success) {
+ // 业务错误,返回错误对象
+ const error = new Error(data.message || 'API request failed')
+ error.code = data.error?.code
+ error.details = data.error?.details
+ error.response = response
+ return Promise.reject(error)
+ }
+
+ // 成功响应,返回完整数据
+ return response
+ }
+
+ // 非对象响应,直接返回
+ return response
+ },
+ (error) => {
+ const apiError = handleApiError(error, 'Response')
+
+ // 根据错误类型显示用户友好的消息
+ let userMessage = apiError.message
+
+ switch (apiError.type) {
+ case 'NETWORK_ERROR':
+ userMessage = '网络连接失败,请检查网络设置'
+ break
+ case 'TIMEOUT_ERROR':
+ userMessage = '请求超时,请稍后重试'
+ break
+ case 'UNAUTHORIZED':
+ userMessage = '未授权访问,请重新登录'
+ // 清除无效的token
+ localStorage.removeItem(STORAGE_KEYS.TOKEN)
+ break
+ case 'FORBIDDEN':
+ userMessage = '权限不足,无法访问该资源'
+ break
+ case 'NOT_FOUND':
+ userMessage = '请求的资源不存在'
+ break
+ case 'SERVICE_UNAVAILABLE':
+ userMessage = '服务暂时不可用,请稍后重试'
+ break
+ default:
+ userMessage = apiError.message || '操作失败,请稍后重试'
+ }
+
+ // 显示错误消息(除了静默错误)
+ if (!error.config?.silent) {
+ ElMessage.error(userMessage)
+ }
+
+ return Promise.reject(apiError)
+ }
+)
+
+// 通用请求方法
+export const apiRequest = {
+ get: (url, config = {}) => request.get(url, config),
+ post: (url, data = {}, config = {}) => request.post(url, data, config),
+ put: (url, data = {}, config = {}) => request.put(url, data, config),
+ delete: (url, config = {}) => request.delete(url, config),
+ patch: (url, data = {}, config = {}) => request.patch(url, data, config)
+}
+
+// 文件上传请求
+export const uploadRequest = (url, formData, onProgress, config = {}) => {
+ return request.post(url, formData, {
+ ...config,
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ ...config.headers
+ },
+ onUploadProgress: (progressEvent) => {
+ if (onProgress && progressEvent.total) {
+ const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
+ onProgress(progress)
+ }
+ }
+ })
+}
+
+// 下载文件请求
+export const downloadRequest = async (url, params = {}, filename = null) => {
+ try {
+ const response = await request.get(url, {
+ params,
+ responseType: 'blob'
+ })
+
+ // 从响应头获取文件名
+ const contentDisposition = response.headers['content-disposition']
+ let defaultFilename = filename || 'download'
+
+ if (contentDisposition) {
+ // RFC 6266/5987: filename*=UTF-8''encoded
+ const filenameStarMatch = contentDisposition.match(/filename\*=(?:UTF-8'')?([^;\n]*)/i)
+ if (filenameStarMatch && filenameStarMatch[1]) {
+ try {
+ defaultFilename = decodeURIComponent(filenameStarMatch[1].replace(/['"]/g, ''))
+ } catch (_) {
+ defaultFilename = filenameStarMatch[1].replace(/['"]/g, '')
+ }
+ } else {
+ const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i)
+ if (filenameMatch && filenameMatch[1]) {
+ defaultFilename = filenameMatch[1].replace(/['"]/g, '')
+ }
+ }
+ }
+
+ const blob = new Blob([response.data])
+ const downloadUrl = window.URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = downloadUrl
+ link.download = defaultFilename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ window.URL.revokeObjectURL(downloadUrl)
+
+ return { success: true, filename: defaultFilename }
+ } catch (error) {
+ console.error('Download failed:', error)
+ throw error
+ }
+}
+
+// 批量请求(支持并发控制)
+export const batchRequest = async (requests, concurrency = 5) => {
+ const results = []
+
+ for (let i = 0; i < requests.length; i += concurrency) {
+ const batch = requests.slice(i, i + concurrency)
+ const batchResults = await Promise.allSettled(
+ batch.map(req => {
+ const { method, url, data, params, config = {} } = req
+ const m = String(method || 'get').toLowerCase()
+ if (m === 'get' || m === 'delete') {
+ return request[m](url, { ...config, params })
+ } else if (m === 'post' || m === 'put' || m === 'patch') {
+ return request[m](url, data, { ...config, params })
+ }
+ return Promise.reject(new Error(`Unsupported method in batchRequest: ${method}`))
+ })
+ )
+ results.push(...batchResults)
+ }
+
+ return results
+}
+
+// 重试请求(支持指数退避)
+export const retryRequest = async (requestFn, maxRetries = 3, baseDelay = 1000) => {
+ let lastError
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ return await requestFn()
+ } catch (error) {
+ lastError = error
+
+ if (attempt === maxRetries) {
+ break
+ }
+
+ // 指数退避
+ const delay = baseDelay * Math.pow(2, attempt - 1)
+ await new Promise(resolve => setTimeout(resolve, delay))
+ }
+ }
+
+ throw lastError
+}
+
+// 取消请求控制器(使用 AbortController)
+export const createAbortController = () => {
+ return new AbortController()
+}
+
+// 兼容旧命名(不再使用 CancelToken)
+export const createCancelToken = () => {
+ const controller = new AbortController()
+ return {
+ token: controller.signal,
+ cancel: () => controller.abort()
+ }
+}
+
+// WebSocket 连接管理
+export const createWebSocket = (url, options = {}) => {
+ const ws = new WebSocket(url)
+
+ ws.onopen = () => {
+ if (import.meta.env.DEV) console.log('WebSocket connected')
+ options.onOpen?.()
+ }
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data)
+ options.onMessage?.(data)
+ } catch (error) {
+ if (import.meta.env.DEV) console.error('WebSocket message parse error:', error)
+ options.onError?.(error)
+ }
+ }
+
+ ws.onclose = () => {
+ if (import.meta.env.DEV) console.log('WebSocket disconnected')
+ options.onClose?.()
+
+ // 自动重连
+ if (options.reconnect !== false) {
+ setTimeout(() => {
+ const newWs = createWebSocket(url, options)
+ options.onReconnect?.(newWs)
+ }, options.reconnectDelay || 3000)
+ }
+ }
+
+ ws.onerror = (error) => {
+ if (import.meta.env.DEV) console.error('WebSocket error:', error)
+ options.onError?.(error)
+ }
+
+ return ws
+}
+
+// 请求缓存
+const requestCache = new Map()
+export const cachedRequest = async (key, requestFn, ttl = 60000) => {
+ const cached = requestCache.get(key)
+
+ if (cached && Date.now() - cached.timestamp < ttl) {
+ return cached.data
+ }
+
+ const data = await requestFn()
+ requestCache.set(key, {
+ data,
+ timestamp: Date.now()
+ })
+
+ return data
+}
+
+// 清除缓存
+export const clearRequestCache = (pattern = null) => {
+ if (pattern) {
+ const regex = new RegExp(pattern)
+ for (const key of requestCache.keys()) {
+ if (regex.test(key)) {
+ requestCache.delete(key)
+ }
+ }
+ } else {
+ requestCache.clear()
+ }
+}
+
+export default request
diff --git a/vue/src/api/store.js b/vue/src/api/store.js
new file mode 100644
index 00000000..92df66f2
--- /dev/null
+++ b/vue/src/api/store.js
@@ -0,0 +1,189 @@
+import { API_ENDPOINTS } from './config'
+import { formatApiPath, extractResponseData } from './utils'
+import { apiRequest } from './request'
+
+/**
+ * Store 级别 API 服务
+ * 对应 MCPStore API v1.0.0 的 Store 级别端点
+ */
+export const storeApi = {
+ /**
+ * 服务同步
+ */
+ syncServices: () => apiRequest.post(API_ENDPOINTS.STORE.SYNC_SERVICES),
+
+ syncStatus: () => apiRequest.get(API_ENDPOINTS.STORE.SYNC_STATUS),
+
+ /**
+ * 服务管理
+ */
+ listServices: () => apiRequest.get(API_ENDPOINTS.STORE.LIST_SERVICES)
+ .then(res => {
+ const data = extractResponseData(res.data, { services: [] })
+ return Array.isArray(data?.services) ? data.services : []
+ }),
+
+ addService: (serviceConfig) => {
+ // 后端已不支持 wait 参数,且不允许空参数;前端进行校验并直传
+ if (!serviceConfig || (typeof serviceConfig === 'object' && Object.keys(serviceConfig).length === 0)) {
+ throw new Error('addService: 必须提供服务配置(后端不再支持空参数全量同步)')
+ }
+ if ('wait' in (serviceConfig || {}) || ('options' in (serviceConfig || {}) && serviceConfig.options && 'wait' in serviceConfig.options)) {
+ throw new Error('addService: 后端不再支持 wait 参数,请移除后重试')
+ }
+ return apiRequest.post(API_ENDPOINTS.STORE.ADD_SERVICE, serviceConfig)
+ },
+
+ initService: (serviceName) => apiRequest.post(API_ENDPOINTS.STORE.INIT_SERVICE, { name: serviceName }),
+
+ deleteService: (serviceName) => apiRequest.delete(
+ formatApiPath(API_ENDPOINTS.STORE.DELETE_SERVICE, { service_name: serviceName })
+ ),
+
+ /**
+ * 工具管理
+ */
+ listTools: () => apiRequest.get(API_ENDPOINTS.STORE.LIST_TOOLS)
+ .then(res => {
+ const data = extractResponseData(res.data, { tools: [] })
+ return Array.isArray(data?.tools) ? data.tools : []
+ }),
+
+ getTools: () => apiRequest.get(API_ENDPOINTS.STORE.LIST_TOOLS)
+ .then(res => {
+ const data = extractResponseData(res.data, { tools: [] })
+ return Array.isArray(data?.tools) ? data.tools : []
+ }),
+
+ getToolInfo: (toolName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.STORE.TOOL_INFO, { tool_name: toolName })
+ ).then(res => extractResponseData(res.data)),
+
+ callTool: (toolName, args, config = {}) => apiRequest.post(API_ENDPOINTS.STORE.CALL_TOOL, {
+ tool_name: toolName,
+ args: args || {}
+ }, config),
+
+ /**
+ * 服务详情
+ */
+ getServiceInfo: (serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.STORE.SERVICE_INFO, { service_name: serviceName })
+ ).then(res => extractResponseData(res.data)),
+
+ getServiceStatus: (serviceName) => apiRequest.get(
+ formatApiPath(API_ENDPOINTS.STORE.SERVICE_STATUS, { service_name: serviceName })
+ ).then(res => extractResponseData(res.data)),
+
+ /**
+ * 健康检查
+ */
+ checkServiceHealth: (serviceName) => apiRequest.post(
+ formatApiPath(API_ENDPOINTS.STORE.SERVICE_HEALTH, { service_name: serviceName })
+ ).then(res => extractResponseData(res.data)),
+
+ checkServices: () => apiRequest.get(API_ENDPOINTS.STORE.CHECK_SERVICES)
+ .then(res => extractResponseData(res.data)),
+
+ getHealth: () => apiRequest.get(API_ENDPOINTS.STORE.HEALTH)
+ .then(res => extractResponseData(res.data)),
+
+ /**
+ * 配置管理
+ */
+ getConfig: (scope = 'all') => apiRequest.get(API_ENDPOINTS.STORE.SHOW_CONFIG, { params: { scope } })
+ .then(res => extractResponseData(res.data)),
+
+ updateConfig: (clientIdOrServiceName, config) => apiRequest.put(
+ formatApiPath(API_ENDPOINTS.STORE.UPDATE_CONFIG, { client_id_or_service_name: clientIdOrServiceName }),
+ config
+ ),
+
+ resetConfig: (scope = 'all') => apiRequest.post(API_ENDPOINTS.STORE.RESET_CONFIG, { scope }),
+
+ /**
+ * MCP JSON 配置管理
+ */
+ getMcpJson: () => apiRequest.get(API_ENDPOINTS.STORE.SHOW_MCPJSON)
+ .then(res => extractResponseData(res.data)),
+
+ resetMcpJson: (config) => apiRequest.post(API_ENDPOINTS.STORE.RESET_MCPJSON, config),
+
+ /**
+ * 统计信息 - 使用 /health 接口(后端没有 get_stats 接口)
+ */
+ getStats: () => apiRequest.get(API_ENDPOINTS.STORE.HEALTH)
+ .then(res => {
+ const health = extractResponseData(res.data, {})
+ // 转换为前端需要的格式
+ return {
+ services_count: health.services_count || 0,
+ agents_count: health.agents_count || 0,
+ status: health.status || 'unknown',
+ uptime_seconds: health.uptime_seconds || 0
+ }
+ }),
+
+ getToolRecords: (limit = 50) => apiRequest.get(API_ENDPOINTS.STORE.TOOL_RECORDS, { params: { limit }})
+ .then(res => extractResponseData(res.data)),
+
+ // 分页获取工具使用记录(对齐 mgmt_vue 参数:tool_name, service_name, page, page_size)
+ getToolRecordsPaged: (params = { page: 1, page_size: 10 }) =>
+ apiRequest.get(API_ENDPOINTS.STORE.TOOL_RECORDS, { params })
+ .then(res => extractResponseData(res.data)),
+
+
+ /**
+ * 系统资源
+ */
+ getSystemResources: () => apiRequest.get(API_ENDPOINTS.STORE.SYSTEM_RESOURCES)
+ .then(res => extractResponseData(res.data)),
+
+ checkNetwork: (endpoints) => apiRequest.post(API_ENDPOINTS.STORE.NETWORK_CHECK, { endpoints }),
+
+ /**
+ * Agent 管理
+ */
+ listAllAgents: () => apiRequest.get(API_ENDPOINTS.STORE.LIST_ALL_AGENTS)
+ .then(res => extractResponseData(res.data, [])),
+
+ listServicesByAgent: (agentId) => apiRequest.get(API_ENDPOINTS.STORE.LIST_SERVICES_BY_AGENT, {
+ params: { agent_id: agentId }
+ }).then(res => extractResponseData(res.data, [])),
+
+ /**
+ * 服务重启
+ */
+ restartService: (serviceName) => apiRequest.post(API_ENDPOINTS.STORE.RESTART_SERVICE, {
+ service_name: serviceName
+ }),
+
+ /**
+ * 服务更新
+ */
+ patchService: (serviceName, updates) => apiRequest.patch(
+ formatApiPath(API_ENDPOINTS.STORE.UPDATE_SERVICE, { service_name: serviceName }),
+ updates
+ ),
+
+ updateService: (serviceName, updates) => apiRequest.patch(
+ formatApiPath(API_ENDPOINTS.STORE.UPDATE_SERVICE, { service_name: serviceName }),
+ updates
+ ),
+
+ /**
+ * 批量操作
+ */
+ batchUpdateServices: (serviceNames, updates) => apiRequest.patch(
+ API_ENDPOINTS.STORE.BATCH_UPDATE_SERVICES,
+ { service_names: serviceNames, updates }
+ ),
+
+ batchDeleteServices: (serviceNames) => apiRequest.post(API_ENDPOINTS.STORE.BATCH_DELETE_SERVICES, {
+ service_names: serviceNames
+ }),
+
+ batchRestartServices: (serviceNames) => apiRequest.post(API_ENDPOINTS.STORE.BATCH_RESTART_SERVICES, {
+ service_names: serviceNames
+ })
+}
diff --git a/vue/src/api/utils.js b/vue/src/api/utils.js
new file mode 100644
index 00000000..e7039a70
--- /dev/null
+++ b/vue/src/api/utils.js
@@ -0,0 +1,184 @@
+import { API_STATUS_CODES, ERROR_TYPES } from './config'
+
+/**
+ * 统一的 API 错误处理类
+ */
+export class APIError extends Error {
+ constructor(message, type = ERROR_TYPES.INTERNAL_ERROR, statusCode = API_STATUS_CODES.INTERNAL_ERROR, details = null) {
+ super(message)
+ this.name = 'APIError'
+ this.type = type
+ this.statusCode = statusCode
+ this.details = details
+ this.timestamp = new Date().toISOString()
+ }
+}
+
+/**
+ * 处理 API 响应错误
+ */
+export function handleApiError(error, context = '') {
+ console.error(`[API Error] ${context}:`, error)
+
+ if (error.response) {
+ // 服务器响应了错误状态码
+ const { status, data } = error.response
+ const message = data?.error?.message || data?.message || error.message
+ const type = data?.error?.code || ERROR_TYPES.INTERNAL_ERROR
+
+ return new APIError(
+ `${context}: ${message}`,
+ type,
+ status,
+ data?.error?.details || data
+ )
+ } else if (error.request) {
+ // 请求已发出但没有收到响应
+ return new APIError(
+ `${context}: Network error - no response received`,
+ ERROR_TYPES.INTERNAL_ERROR,
+ 0,
+ { request: error.request }
+ )
+ } else {
+ // 请求设置时出错
+ return new APIError(
+ `${context}: ${error.message}`,
+ ERROR_TYPES.INTERNAL_ERROR,
+ 0,
+ { originalError: error }
+ )
+ }
+}
+
+/**
+ * 验证 API 响应格式
+ */
+export function validateApiResponse(response) {
+ if (!response || typeof response !== 'object') {
+ throw new APIError('Invalid API response format', ERROR_TYPES.VALIDATION_ERROR)
+ }
+
+ // 检查是否有 success 字段
+ if ('success' in response && typeof response.success !== 'boolean') {
+ throw new APIError('Invalid success field in response', ERROR_TYPES.VALIDATION_ERROR)
+ }
+
+ return response
+}
+
+/**
+ * 提取响应数据
+ */
+export function extractResponseData(response, defaultValue = null) {
+ const validated = validateApiResponse(response)
+
+ // 若没有 success 字段,直接返回整体响应(兼容多种后端格式)
+ if (!('success' in validated)) {
+ return validated ?? defaultValue
+ }
+
+ if (validated.success) {
+ return validated.data ?? defaultValue
+ } else {
+ throw new APIError(
+ validated.message || 'API request failed',
+ validated.error?.code || ERROR_TYPES.INTERNAL_ERROR,
+ validated.error?.statusCode || API_STATUS_CODES.INTERNAL_ERROR,
+ validated.error
+ )
+ }
+}
+
+/**
+ * 格式化 API 路径参数
+ */
+export function formatApiPath(path, params = {}) {
+ let formattedPath = path
+
+ for (const [key, value] of Object.entries(params)) {
+ formattedPath = formattedPath.replace(`{${key}}`, encodeURIComponent(value))
+ }
+
+ return formattedPath
+}
+
+/**
+ * 创建查询字符串
+ */
+export function buildQueryString(params = {}) {
+ const searchParams = new URLSearchParams()
+
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null && value !== undefined) {
+ if (Array.isArray(value)) {
+ value.forEach(item => searchParams.append(key, item))
+ } else {
+ searchParams.append(key, value)
+ }
+ }
+ }
+
+ return searchParams.toString()
+}
+
+
+/**
+ * 深度合并对象
+ */
+export function deepMerge(target, source) {
+ const output = Object.assign({}, target)
+
+ if (isObject(target) && isObject(source)) {
+ Object.keys(source).forEach(key => {
+ if (isObject(source[key])) {
+ if (!(key in target))
+ Object.assign(output, { [key]: source[key] })
+ else
+ output[key] = deepMerge(target[key], source[key])
+ } else {
+ Object.assign(output, { [key]: source[key] })
+ }
+ })
+ }
+
+ return output
+}
+
+function isObject(item) {
+ return item && typeof item === 'object' && !Array.isArray(item)
+}
+
+/**
+ * 生成唯一 ID
+ */
+export function generateUniqueId() {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2)
+}
+
+/**
+ * 格式化文件大小
+ */
+export function formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+/**
+ * 格式化持续时间
+ */
+export function formatDuration(ms) {
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+ if (ms < 3600000) return `${(ms / 60000).toFixed(1)}min`
+
+ const hours = Math.floor(ms / 3600000)
+ const minutes = Math.floor((ms % 3600000) / 60000)
+
+ return `${hours}h ${minutes}m`
+}
diff --git a/vue/src/components/BatchOperations.vue b/vue/src/components/BatchOperations.vue
new file mode 100644
index 00000000..a213c074
--- /dev/null
+++ b/vue/src/components/BatchOperations.vue
@@ -0,0 +1,530 @@
+
+
+
+
+
+
+
+
+
{{ selectedItems.length }} 项已选中
+
+
+
+ 编辑
+
+
+ 删除
+
+
+
+ 清除选择
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ 确定
+
+
+
+
+
+
+
+
+
+
+
确定要删除选中的 {{ selectedItems.length }} 项吗?
+
+ 此操作不可撤销,请谨慎操作。
+
+
+
+ {{ getItemName(item) }}
+
+
+ ... 还有 {{ selectedItems.length - 5 }} 项
+
+
+
+
+
+
+ 取消
+
+
+ 删除
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/components/ServiceDetailsTable.vue b/vue/src/components/ServiceDetailsTable.vue
new file mode 100644
index 00000000..ccfc5667
--- /dev/null
+++ b/vue/src/components/ServiceDetailsTable.vue
@@ -0,0 +1,472 @@
+
+
+
+
+
+
+
{{ record.serviceName }}
+
+ {{ record.agentId }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatResponseTime(text) }}
+
+
+
+
+
+
+
{{ formatTimestamp(text) }}
+
+ {{ getTimeDifference(text * 1000) }}
+
+
+
+
+
+
+
+
+
{{ record.consecutiveSuccesses }}✓
+
+
+
+
+
+
+
+
+ {{ record.toolsCount || 0 }} 工具
+
+
+
+ {{ formatISOTime(record.lastUpdated) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 断开连接
+
+
+
+ 查看快照
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/components/ServiceLifecycleStatus.vue b/vue/src/components/ServiceLifecycleStatus.vue
new file mode 100644
index 00000000..316b675b
--- /dev/null
+++ b/vue/src/components/ServiceLifecycleStatus.vue
@@ -0,0 +1,336 @@
+
+
+
+
+
+ {{ getServiceStateText(status) }}
+
+
+
+
+
+
+ {{ serviceName }}
+
+
+
+ {{ getServiceStateText(status) }}
+
+
+
+ {{ formatResponseTime(responseTime) }}
+
+
+ {{ consecutiveFailures || 0 }} 次
+
+
+ {{ consecutiveSuccesses || 0 }} 次
+
+
+ {{ reconnectAttempts || 0 }} 次
+
+
+ {{ formatISOTime(stateEnteredTime) }}
+
+
+ {{ formatTimestamp(lastCheckTime) }}
+
+
+ {{ formatISOTime(nextRetryTime) }}
+
+
+
+
+
+
+
+
+
+
+
+ 刷新内容
+
+
+ 健康检查
+
+
+ 断开连接
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/components/ServiceStatusSummary.vue b/vue/src/components/ServiceStatusSummary.vue
new file mode 100644
index 00000000..0a34d4c2
--- /dev/null
+++ b/vue/src/components/ServiceStatusSummary.vue
@@ -0,0 +1,383 @@
+
+
+
+
+
+
+
+ {{ stats.total }}
+
+
+ 总服务
+
+
+
+
+
+
+
+ {{ stats.healthy }}
+
+
+ 健康
+
+
+
+
+
+
+
+ {{ stats.warning }}
+
+
+ 警告
+
+
+
+
+
+
+
+ {{ stats.reconnecting }}
+
+
+ 重连中
+
+
+
+
+
+
+
+ {{ stats.unreachable }}
+
+
+ 无法访问
+
+
+
+
+
+
+
+ {{ stats.initializing }}
+
+
+ 初始化中
+
+
+
+
+
+
+
+ {{ stats.disconnected + stats.disconnecting }}
+
+
+ 已断连
+
+
+
+
+
+
+
+
+
+ 系统健康度: {{ healthScore }}%
+ {{ healthStatusText }}
+
+
+
+
+
+
+
+ 刷新所有服务
+
+
+ 刷新状态
+
+
+ 查看详情
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/components/agents/ServiceForm.vue b/vue/src/components/agents/ServiceForm.vue
new file mode 100644
index 00000000..07e931b3
--- /dev/null
+++ b/vue/src/components/agents/ServiceForm.vue
@@ -0,0 +1,803 @@
+
+
+
+
+
+
+
+
+
+
+ Remote (HTTP/SSE)
+
+
+
+ Local (Stdio)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ adding ? '添加中...' : '提交' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ AGENT
+ {{ previewService.agentId || 'Store (无 Agent)' }}
+
+
+ SERVICE
+ {{ previewService.name }}
+
+
+ TYPE
+ {{ previewService.type }}
+
+
+
+
+ URL
+ {{ previewService.url || '-' }}
+
+
+ TRANSPORT
+ {{ previewService.transport }}
+
+
+
+
+
+ COMMAND
+ $ {{ previewService.command || '-' }}
+
+
+ ARGS
+ {{ previewService.args.join(' ') }}
+
+
+
+
+
+
PAYLOAD
+
{{ configPreview }}
+
+
+
+ 填写表单后可预览提交内容
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/components/charts/PerformanceChart.vue b/vue/src/components/charts/PerformanceChart.vue
new file mode 100644
index 00000000..06c0384e
--- /dev/null
+++ b/vue/src/components/charts/PerformanceChart.vue
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
diff --git a/vue/src/components/common/ChartCard.vue b/vue/src/components/common/ChartCard.vue
new file mode 100644
index 00000000..5eee199b
--- /dev/null
+++ b/vue/src/components/common/ChartCard.vue
@@ -0,0 +1,402 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/components/common/PageLoading.vue b/vue/src/components/common/PageLoading.vue
new file mode 100644
index 00000000..41389aff
--- /dev/null
+++ b/vue/src/components/common/PageLoading.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
diff --git a/vue/src/components/common/StatCard.vue b/vue/src/components/common/StatCard.vue
new file mode 100644
index 00000000..1b43c02b
--- /dev/null
+++ b/vue/src/components/common/StatCard.vue
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+ {{ displayValue }}
+ {{ unit }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/components/common/StatusBadge.vue b/vue/src/components/common/StatusBadge.vue
new file mode 100644
index 00000000..fc44143c
--- /dev/null
+++ b/vue/src/components/common/StatusBadge.vue
@@ -0,0 +1,147 @@
+
+
+ {{ displayText }}
+
+
+
+
+
+
diff --git a/vue/src/components/config/JsonEditor.vue b/vue/src/components/config/JsonEditor.vue
new file mode 100644
index 00000000..7ef5823d
--- /dev/null
+++ b/vue/src/components/config/JsonEditor.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
diff --git a/vue/src/components/layout/TabsView.vue b/vue/src/components/layout/TabsView.vue
new file mode 100644
index 00000000..845479e9
--- /dev/null
+++ b/vue/src/components/layout/TabsView.vue
@@ -0,0 +1,128 @@
+
+
+
+ {{ tab.title }}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/composables/index.js b/vue/src/composables/index.js
new file mode 100644
index 00000000..b2e87471
--- /dev/null
+++ b/vue/src/composables/index.js
@@ -0,0 +1,8 @@
+/**
+ * Composables 统一导出
+ */
+
+export * from './useErrorHandler'
+export * from './useLoadingState'
+export * from './useApi'
+
diff --git a/vue/src/composables/useApi.js b/vue/src/composables/useApi.js
new file mode 100644
index 00000000..0007b782
--- /dev/null
+++ b/vue/src/composables/useApi.js
@@ -0,0 +1,504 @@
+/**
+ * 统一的 API 调用 Composable
+ * 封装常见的 API 调用模式,自动处理加载状态、错误处理和数据管理
+ */
+
+import { ref, computed, unref } from 'vue'
+import { useErrorHandler } from './useErrorHandler'
+import { useLoadingState } from './useLoadingState'
+
+/**
+ * 创建 API 调用管理器
+ * @param {Function} apiFn - API 调用函数
+ * @param {Object} options - 配置选项
+ * @returns {Object} API 调用管理器实例
+ */
+export function useApi(apiFn, options = {}) {
+ const {
+ immediate = false,
+ initialData = null,
+ loadingKey = 'api',
+ errorHandler = null,
+ transform = null,
+ onSuccess = null,
+ onError = null,
+ cache = false,
+ cacheTime = 60000
+ } = options
+
+ // 使用 composables
+ const errorHandlerInstance = errorHandler || useErrorHandler({ source: 'api' })
+ const loadingStateInstance = useLoadingState({ [loadingKey]: false })
+
+ // 状态
+ const data = ref(initialData)
+ const error = ref(null)
+ const isLoading = computed(() => loadingStateInstance.getLoading(loadingKey))
+ const isReady = ref(false)
+ const lastFetchTime = ref(null)
+
+ // 缓存
+ const cacheData = cache ? new Map() : null
+ const cacheTimestamps = cache ? new Map() : null
+
+ /**
+ * 执行 API 调用
+ * @param {...any} args - API 函数参数
+ * @returns {Promise} API 调用结果
+ */
+ const execute = async (...args) => {
+ // 检查缓存
+ if (cache) {
+ const cacheKey = JSON.stringify(args)
+ const cachedValue = cacheData.get(cacheKey)
+ const cacheTimestamp = cacheTimestamps.get(cacheKey)
+
+ if (cachedValue && cacheTimestamp && (Date.now() - cacheTimestamp < cacheTime)) {
+ data.value = cachedValue
+ return cachedValue
+ }
+ }
+
+ loadingStateInstance.setLoading(loadingKey, true)
+ error.value = null
+
+ try {
+ const response = await apiFn(...args)
+
+ // 提取数据
+ let result = response?.data ?? response
+
+ // 转换数据
+ if (transform) {
+ result = transform(result)
+ }
+
+ data.value = result
+ isReady.value = true
+ lastFetchTime.value = Date.now()
+
+ // 缓存结果
+ if (cache) {
+ const cacheKey = JSON.stringify(args)
+ cacheData.set(cacheKey, result)
+ cacheTimestamps.set(cacheKey, Date.now())
+ }
+
+ // 成功回调
+ if (onSuccess) {
+ onSuccess(result, response)
+ }
+
+ return result
+ } catch (err) {
+ error.value = err
+
+ // 错误处理
+ const errorObj = errorHandlerInstance.handleApiError(err, 'API调用失败')
+
+ // 错误回调
+ if (onError) {
+ onError(errorObj, err)
+ }
+
+ throw err
+ } finally {
+ loadingStateInstance.setLoading(loadingKey, false)
+ }
+ }
+
+ /**
+ * 重新执行 API 调用(刷新)
+ * @param {...any} args - API 函数参数
+ */
+ const refresh = async (...args) => {
+ // 清除缓存
+ if (cache) {
+ clearCache()
+ }
+ return execute(...args)
+ }
+
+ /**
+ * 重置状态
+ */
+ const reset = () => {
+ data.value = initialData
+ error.value = null
+ isReady.value = false
+ lastFetchTime.value = null
+ loadingStateInstance.reset(loadingKey)
+
+ if (cache) {
+ clearCache()
+ }
+ }
+
+ /**
+ * 清除缓存
+ */
+ const clearCache = () => {
+ if (cache) {
+ cacheData.clear()
+ cacheTimestamps.clear()
+ }
+ }
+
+ // 立即执行
+ if (immediate) {
+ execute()
+ }
+
+ return {
+ // 状态
+ data,
+ error,
+ isLoading,
+ isReady,
+ lastFetchTime,
+
+ // 方法
+ execute,
+ refresh,
+ reset,
+ clearCache
+ }
+}
+
+/**
+ * 批量 API 调用管理器
+ * @param {Array} apiFns - API 调用函数数组
+ * @param {Object} options - 配置选项
+ * @returns {Object} 批量 API 调用管理器实例
+ */
+export function useBatchApi(apiFns, options = {}) {
+ const {
+ immediate = false,
+ parallel = true,
+ errorHandler = null,
+ onComplete = null
+ } = options
+
+ const errorHandlerInstance = errorHandler || useErrorHandler({ source: 'batch-api' })
+ const loadingStateInstance = useLoadingState({ batch: false })
+
+ const results = ref([])
+ const errors = ref([])
+ const isLoading = computed(() => loadingStateInstance.getLoading('batch'))
+ const isComplete = ref(false)
+
+ /**
+ * 执行所有 API 调用
+ * @param {...any} args - 传递给所有 API 函数的参数
+ */
+ const executeAll = async (...args) => {
+ loadingStateInstance.setLoading('batch', true)
+ errors.value = []
+ isComplete.value = false
+
+ try {
+ if (parallel) {
+ // 并行执行
+ const promises = apiFns.map(fn =>
+ fn(...args).catch(err => {
+ errors.value.push(err)
+ errorHandlerInstance.handleApiError(err, '批量API调用部分失败')
+ return null
+ })
+ )
+ results.value = await Promise.all(promises)
+ } else {
+ // 串行执行
+ results.value = []
+ for (const fn of apiFns) {
+ try {
+ const result = await fn(...args)
+ results.value.push(result)
+ } catch (err) {
+ errors.value.push(err)
+ errorHandlerInstance.handleApiError(err, '批量API调用部分失败')
+ results.value.push(null)
+ }
+ }
+ }
+
+ isComplete.value = true
+
+ if (onComplete) {
+ onComplete(results.value, errors.value)
+ }
+
+ return results.value
+ } finally {
+ loadingStateInstance.setLoading('batch', false)
+ }
+ }
+
+ /**
+ * 重置状态
+ */
+ const reset = () => {
+ results.value = []
+ errors.value = []
+ isComplete.value = false
+ loadingStateInstance.reset('batch')
+ }
+
+ if (immediate) {
+ executeAll()
+ }
+
+ return {
+ // 状态
+ results,
+ errors,
+ isLoading,
+ isComplete,
+
+ // 方法
+ executeAll,
+ reset
+ }
+}
+
+/**
+ * 轮询 API 调用管理器
+ * @param {Function} apiFn - API 调用函数
+ * @param {Object} options - 配置选项
+ * @returns {Object} 轮询管理器实例
+ */
+export function usePollingApi(apiFn, options = {}) {
+ const {
+ interval = 5000,
+ immediate = false,
+ enabled = true,
+ errorHandler = null,
+ onSuccess = null,
+ onError = null
+ } = options
+
+ const errorHandlerInstance = errorHandler || useErrorHandler({ source: 'polling-api' })
+
+ const data = ref(null)
+ const error = ref(null)
+ const isPolling = ref(false)
+ const pollCount = ref(0)
+ let timerId = null
+
+ /**
+ * 执行单次轮询
+ */
+ const poll = async () => {
+ try {
+ const response = await apiFn()
+ const result = response?.data ?? response
+
+ data.value = result
+ error.value = null
+ pollCount.value++
+
+ if (onSuccess) {
+ onSuccess(result, response)
+ }
+
+ return result
+ } catch (err) {
+ error.value = err
+ errorHandlerInstance.handleApiError(err, '轮询API调用失败', { silent: true })
+
+ if (onError) {
+ onError(err)
+ }
+
+ throw err
+ }
+ }
+
+ /**
+ * 开始轮询
+ */
+ const start = () => {
+ if (isPolling.value) return
+
+ isPolling.value = true
+
+ // 立即执行一次
+ if (immediate) {
+ poll()
+ }
+
+ // 设置定时器
+ timerId = setInterval(() => {
+ if (enabled) {
+ poll()
+ }
+ }, unref(interval))
+ }
+
+ /**
+ * 停止轮询
+ */
+ const stop = () => {
+ if (timerId) {
+ clearInterval(timerId)
+ timerId = null
+ }
+ isPolling.value = false
+ }
+
+ /**
+ * 重置状态
+ */
+ const reset = () => {
+ stop()
+ data.value = null
+ error.value = null
+ pollCount.value = 0
+ }
+
+ // 自动开始
+ if (enabled && immediate) {
+ start()
+ }
+
+ return {
+ // 状态
+ data,
+ error,
+ isPolling,
+ pollCount,
+
+ // 方法
+ start,
+ stop,
+ reset,
+ poll
+ }
+}
+
+/**
+ * 分页 API 调用管理器
+ * @param {Function} apiFn - API 调用函数
+ * @param {Object} options - 配置选项
+ * @returns {Object} 分页管理器实例
+ */
+export function usePaginationApi(apiFn, options = {}) {
+ const {
+ initialPage = 1,
+ initialPageSize = 20,
+ immediate = false,
+ errorHandler = null,
+ transform = null
+ } = options
+
+ const errorHandlerInstance = errorHandler || useErrorHandler({ source: 'pagination-api' })
+ const loadingStateInstance = useLoadingState({ pagination: false })
+
+ const data = ref([])
+ const total = ref(0)
+ const currentPage = ref(initialPage)
+ const pageSize = ref(initialPageSize)
+ const isLoading = computed(() => loadingStateInstance.getLoading('pagination'))
+
+ /**
+ * 加载数据
+ * @param {number} page - 页码
+ * @param {number} size - 每页数量
+ */
+ const load = async (page = currentPage.value, size = pageSize.value) => {
+ loadingStateInstance.setLoading('pagination', true)
+
+ try {
+ const params = { page, page_size: size, pageSize: size }
+ const response = await apiFn(params)
+ let result = response?.data ?? response
+
+ if (transform) {
+ result = transform(result)
+ }
+
+ data.value = result.items || result.data || result
+ total.value = result.total || result.count || data.value.length
+ currentPage.value = page
+ pageSize.value = size
+
+ return result
+ } catch (err) {
+ errorHandlerInstance.handleApiError(err, '加载分页数据失败')
+ throw err
+ } finally {
+ loadingStateInstance.setLoading('pagination', false)
+ }
+ }
+
+ /**
+ * 下一页
+ */
+ const nextPage = () => {
+ const totalPages = Math.ceil(total.value / pageSize.value)
+ if (currentPage.value < totalPages) {
+ return load(currentPage.value + 1)
+ }
+ }
+
+ /**
+ * 上一页
+ */
+ const prevPage = () => {
+ if (currentPage.value > 1) {
+ return load(currentPage.value - 1)
+ }
+ }
+
+ /**
+ * 跳转到指定页
+ * @param {number} page - 页码
+ */
+ const goToPage = (page) => {
+ return load(page)
+ }
+
+ /**
+ * 刷新当前页
+ */
+ const refresh = () => {
+ return load()
+ }
+
+ /**
+ * 重置
+ */
+ const reset = () => {
+ data.value = []
+ total.value = 0
+ currentPage.value = initialPage
+ pageSize.value = initialPageSize
+ }
+
+ if (immediate) {
+ load()
+ }
+
+ return {
+ // 状态
+ data,
+ total,
+ currentPage,
+ pageSize,
+ isLoading,
+
+ // 计算属性
+ totalPages: computed(() => Math.ceil(total.value / pageSize.value)),
+ hasNextPage: computed(() => currentPage.value < Math.ceil(total.value / pageSize.value)),
+ hasPrevPage: computed(() => currentPage.value > 1),
+
+ // 方法
+ load,
+ nextPage,
+ prevPage,
+ goToPage,
+ refresh,
+ reset
+ }
+}
+
diff --git a/vue/src/composables/useErrorHandler.js b/vue/src/composables/useErrorHandler.js
new file mode 100644
index 00000000..8b4ed7c4
--- /dev/null
+++ b/vue/src/composables/useErrorHandler.js
@@ -0,0 +1,315 @@
+/**
+ * 统一的错误处理 Composable
+ * 提供错误状态管理、错误记录和错误通知功能
+ */
+
+import { ref, computed } from 'vue'
+import { ElMessage, ElNotification } from 'element-plus'
+
+/**
+ * 创建错误处理器
+ * @param {Object} options - 配置选项
+ * @param {number} options.maxErrors - 最大错误记录数,默认 100
+ * @param {boolean} options.showNotification - 是否显示通知,默认 true
+ * @param {string} options.source - 错误来源标识
+ * @returns {Object} 错误处理器实例
+ */
+export function useErrorHandler(options = {}) {
+ const {
+ maxErrors = 100,
+ showNotification = true,
+ source = 'unknown'
+ } = options
+
+ // 状态
+ const errors = ref([])
+ const lastError = ref(null)
+
+ // 计算属性
+ /**
+ * 是否有错误
+ */
+ const hasErrors = computed(() => errors.value.length > 0)
+
+ /**
+ * 最近的错误列表(最多5个)
+ */
+ const recentErrors = computed(() => {
+ return errors.value.slice(-5).reverse()
+ })
+
+ /**
+ * 错误数量
+ */
+ const errorCount = computed(() => errors.value.length)
+
+ /**
+ * 添加错误
+ * @param {Error|string|Object} error - 错误对象或错误信息
+ * @param {Object} additionalInfo - 额外信息
+ * @returns {Object} 错误对象
+ */
+ const addError = (error, additionalInfo = {}) => {
+ const errorObj = {
+ id: Date.now() + Math.random(), // 确保唯一性
+ message: error?.message || error || '未知错误',
+ stack: error?.stack,
+ timestamp: new Date().toISOString(),
+ type: error?.type || additionalInfo.type || 'error',
+ source: additionalInfo.source || source,
+ code: error?.code || additionalInfo.code,
+ details: error?.details || additionalInfo.details
+ }
+
+ errors.value.push(errorObj)
+ lastError.value = errorObj
+
+ // 限制错误数量
+ if (errors.value.length > maxErrors) {
+ errors.value = errors.value.slice(-maxErrors)
+ }
+
+ // 显示通知
+ if (showNotification && !additionalInfo.silent) {
+ showErrorNotification(errorObj)
+ }
+
+ return errorObj
+ }
+
+ /**
+ * 显示错误通知
+ * @param {Object} errorObj - 错误对象
+ */
+ const showErrorNotification = (errorObj) => {
+ const message = errorObj.message || '操作失败'
+
+ // 根据错误类型选择通知方式
+ if (errorObj.type === 'warning') {
+ ElMessage.warning({
+ message,
+ duration: 3000,
+ showClose: true
+ })
+ } else if (errorObj.type === 'critical' || errorObj.type === 'fatal') {
+ ElNotification.error({
+ title: '严重错误',
+ message,
+ duration: 0, // 不自动关闭
+ position: 'top-right'
+ })
+ } else {
+ ElMessage.error({
+ message,
+ duration: 3000,
+ showClose: true
+ })
+ }
+ }
+
+ /**
+ * 清除所有错误
+ */
+ const clearErrors = () => {
+ errors.value = []
+ lastError.value = null
+ }
+
+ /**
+ * 移除特定错误
+ * @param {number|string} errorId - 错误ID
+ */
+ const removeError = (errorId) => {
+ const index = errors.value.findIndex(error => error.id === errorId)
+ if (index > -1) {
+ errors.value.splice(index, 1)
+
+ // 如果删除的是最后一个错误,更新 lastError
+ if (lastError.value?.id === errorId) {
+ lastError.value = errors.value[errors.value.length - 1] || null
+ }
+ }
+ }
+
+ /**
+ * 根据类型清除错误
+ * @param {string} type - 错误类型
+ */
+ const clearErrorsByType = (type) => {
+ errors.value = errors.value.filter(error => error.type !== type)
+
+ // 如果 lastError 被清除,更新为最新的错误
+ if (lastError.value?.type === type) {
+ lastError.value = errors.value[errors.value.length - 1] || null
+ }
+ }
+
+ /**
+ * 根据来源清除错误
+ * @param {string} errorSource - 错误来源
+ */
+ const clearErrorsBySource = (errorSource) => {
+ errors.value = errors.value.filter(error => error.source !== errorSource)
+
+ if (lastError.value?.source === errorSource) {
+ lastError.value = errors.value[errors.value.length - 1] || null
+ }
+ }
+
+ /**
+ * 获取特定类型的错误
+ * @param {string} type - 错误类型
+ * @returns {Array} 错误列表
+ */
+ const getErrorsByType = (type) => {
+ return errors.value.filter(error => error.type === type)
+ }
+
+ /**
+ * 获取特定来源的错误
+ * @param {string} errorSource - 错误来源
+ * @returns {Array} 错误列表
+ */
+ const getErrorsBySource = (errorSource) => {
+ return errors.value.filter(error => error.source === errorSource)
+ }
+
+ /**
+ * 处理API错误的辅助函数
+ * @param {Error} error - API错误对象
+ * @param {string} context - 错误上下文
+ * @param {Object} options - 额外选项
+ */
+ const handleApiError = (error, context = '', options = {}) => {
+ let errorMessage = '操作失败'
+ let errorType = 'error'
+
+ // 解析不同类型的错误
+ if (error.response) {
+ // HTTP 错误响应
+ const status = error.response.status
+ const data = error.response.data
+
+ switch (status) {
+ case 400:
+ errorMessage = data?.message || '请求参数错误'
+ errorType = 'validation'
+ break
+ case 401:
+ errorMessage = '未授权,请重新登录'
+ errorType = 'auth'
+ break
+ case 403:
+ errorMessage = '权限不足'
+ errorType = 'permission'
+ break
+ case 404:
+ errorMessage = data?.message || '资源不存在'
+ errorType = 'not-found'
+ break
+ case 500:
+ errorMessage = '服务器内部错误'
+ errorType = 'server'
+ break
+ case 503:
+ errorMessage = '服务暂时不可用'
+ errorType = 'unavailable'
+ break
+ default:
+ errorMessage = data?.message || error.message || `请求失败 (${status})`
+ }
+ } else if (error.request) {
+ // 网络错误
+ errorMessage = '网络连接失败,请检查网络设置'
+ errorType = 'network'
+ } else {
+ // 其他错误
+ errorMessage = error.message || '未知错误'
+ }
+
+ // 添加上下文信息
+ if (context) {
+ errorMessage = `${context}: ${errorMessage}`
+ }
+
+ return addError(error, {
+ type: errorType,
+ message: errorMessage,
+ ...options
+ })
+ }
+
+ /**
+ * 包装异步函数,自动处理错误
+ * @param {Function} asyncFn - 异步函数
+ * @param {Object} options - 配置选项
+ * @returns {Function} 包装后的函数
+ */
+ const withErrorHandling = (asyncFn, options = {}) => {
+ return async (...args) => {
+ try {
+ return await asyncFn(...args)
+ } catch (error) {
+ handleApiError(error, options.context, {
+ source: options.source || source,
+ silent: options.silent
+ })
+
+ if (options.rethrow) {
+ throw error
+ }
+
+ return options.fallbackValue
+ }
+ }
+ }
+
+ return {
+ // 状态
+ errors,
+ lastError,
+
+ // 计算属性
+ hasErrors,
+ recentErrors,
+ errorCount,
+
+ // 方法
+ addError,
+ clearErrors,
+ removeError,
+ clearErrorsByType,
+ clearErrorsBySource,
+ getErrorsByType,
+ getErrorsBySource,
+ handleApiError,
+ withErrorHandling,
+ showErrorNotification
+ }
+}
+
+/**
+ * 创建全局错误处理器(单例模式)
+ */
+let globalErrorHandler = null
+
+export function useGlobalErrorHandler() {
+ if (!globalErrorHandler) {
+ globalErrorHandler = useErrorHandler({
+ maxErrors: 200,
+ source: 'global'
+ })
+ }
+ return globalErrorHandler
+}
+
+/**
+ * 重置全局错误处理器
+ */
+export function resetGlobalErrorHandler() {
+ if (globalErrorHandler) {
+ globalErrorHandler.clearErrors()
+ globalErrorHandler = null
+ }
+}
+
diff --git a/vue/src/composables/useLoadingState.js b/vue/src/composables/useLoadingState.js
new file mode 100644
index 00000000..e7823222
--- /dev/null
+++ b/vue/src/composables/useLoadingState.js
@@ -0,0 +1,322 @@
+/**
+ * 统一的加载状态管理 Composable
+ * 提供细粒度的加载状态管理,支持多个独立的加载状态
+ */
+
+import { ref, computed, reactive } from 'vue'
+
+/**
+ * 创建加载状态管理器
+ * @param {Object|Array} initialStates - 初始加载状态配置
+ * @returns {Object} 加载状态管理器实例
+ */
+export function useLoadingState(initialStates = {}) {
+ // 如果传入数组,转换为对象格式
+ const states = Array.isArray(initialStates)
+ ? initialStates.reduce((acc, key) => ({ ...acc, [key]: false }), {})
+ : { ...initialStates }
+
+ // 使用 reactive 使对象响应式
+ const loadingStates = reactive(states)
+
+ // 全局加载状态
+ const globalLoading = ref(false)
+
+ // 计算属性
+
+ /**
+ * 是否有任何加载状态为 true
+ */
+ const isLoading = computed(() => {
+ return globalLoading.value || Object.values(loadingStates).some(Boolean)
+ })
+
+ /**
+ * 当前活跃的加载状态列表
+ */
+ const activeLoadingStates = computed(() => {
+ return Object.entries(loadingStates)
+ .filter(([, value]) => value)
+ .map(([key]) => key)
+ })
+
+ /**
+ * 活跃的加载状态数量
+ */
+ const activeLoadingCount = computed(() => {
+ return activeLoadingStates.value.length
+ })
+
+ // 方法
+
+ /**
+ * 设置特定加载状态
+ * @param {string} key - 状态键名
+ * @param {boolean} value - 状态值
+ */
+ const setLoading = (key, value) => {
+ if (key in loadingStates) {
+ loadingStates[key] = value
+ } else {
+ console.warn(`[useLoadingState] 未知的加载状态: ${key}`)
+ // 动态添加新状态
+ loadingStates[key] = value
+ }
+ }
+
+ /**
+ * 获取特定加载状态
+ * @param {string} key - 状态键名
+ * @returns {boolean} 加载状态值
+ */
+ const getLoading = (key) => {
+ return loadingStates[key] || false
+ }
+
+ /**
+ * 设置全局加载状态
+ * @param {boolean} value - 状态值
+ */
+ const setGlobalLoading = (value) => {
+ globalLoading.value = value
+ }
+
+ /**
+ * 开始加载
+ * @param {string|string[]} keys - 状态键名或键名数组
+ */
+ const startLoading = (keys) => {
+ const keyArray = Array.isArray(keys) ? keys : [keys]
+ keyArray.forEach(key => setLoading(key, true))
+ }
+
+ /**
+ * 停止加载
+ * @param {string|string[]} keys - 状态键名或键名数组
+ */
+ const stopLoading = (keys) => {
+ const keyArray = Array.isArray(keys) ? keys : [keys]
+ keyArray.forEach(key => setLoading(key, false))
+ }
+
+ /**
+ * 重置所有加载状态
+ */
+ const resetAll = () => {
+ Object.keys(loadingStates).forEach(key => {
+ loadingStates[key] = false
+ })
+ globalLoading.value = false
+ }
+
+ /**
+ * 重置特定的加载状态
+ * @param {string|string[]} keys - 状态键名或键名数组
+ */
+ const reset = (keys) => {
+ const keyArray = Array.isArray(keys) ? keys : [keys]
+ keyArray.forEach(key => {
+ if (key in loadingStates) {
+ loadingStates[key] = false
+ }
+ })
+ }
+
+ /**
+ * 添加新的加载状态
+ * @param {string} key - 状态键名
+ * @param {boolean} initialValue - 初始值,默认 false
+ */
+ const addState = (key, initialValue = false) => {
+ if (!(key in loadingStates)) {
+ loadingStates[key] = initialValue
+ }
+ }
+
+ /**
+ * 移除加载状态
+ * @param {string} key - 状态键名
+ */
+ const removeState = (key) => {
+ if (key in loadingStates) {
+ delete loadingStates[key]
+ }
+ }
+
+ /**
+ * 包装异步函数,自动管理加载状态
+ * @param {Function} asyncFn - 异步函数
+ * @param {string|string[]} loadingKeys - 要管理的加载状态键
+ * @param {Object} options - 配置选项
+ * @returns {Function} 包装后的函数
+ */
+ const withLoading = (asyncFn, loadingKeys, options = {}) => {
+ const {
+ useGlobal = false,
+ onError = null,
+ finally: finallyCallback = null
+ } = options
+
+ return async (...args) => {
+ try {
+ if (useGlobal) {
+ setGlobalLoading(true)
+ } else {
+ startLoading(loadingKeys)
+ }
+
+ return await asyncFn(...args)
+ } catch (error) {
+ if (onError) {
+ onError(error)
+ } else {
+ throw error
+ }
+ } finally {
+ if (useGlobal) {
+ setGlobalLoading(false)
+ } else {
+ stopLoading(loadingKeys)
+ }
+
+ if (finallyCallback) {
+ finallyCallback()
+ }
+ }
+ }
+ }
+
+ /**
+ * 创建一个加载状态追踪器
+ * @param {string} key - 状态键名
+ * @returns {Object} 包含 start、stop、toggle 和 isLoading 的对象
+ */
+ const createTracker = (key) => {
+ // 确保状态存在
+ if (!(key in loadingStates)) {
+ addState(key)
+ }
+
+ return {
+ /**
+ * 开始加载
+ */
+ start: () => setLoading(key, true),
+
+ /**
+ * 停止加载
+ */
+ stop: () => setLoading(key, false),
+
+ /**
+ * 切换加载状态
+ */
+ toggle: () => setLoading(key, !loadingStates[key]),
+
+ /**
+ * 当前加载状态
+ */
+ isLoading: computed(() => loadingStates[key])
+ }
+ }
+
+ /**
+ * 批量设置多个加载状态
+ * @param {Object} states - 状态对象 { key1: true, key2: false, ... }
+ */
+ const setBatch = (states) => {
+ Object.entries(states).forEach(([key, value]) => {
+ setLoading(key, value)
+ })
+ }
+
+ /**
+ * 获取所有加载状态的快照
+ * @returns {Object} 加载状态快照
+ */
+ const getSnapshot = () => {
+ return {
+ global: globalLoading.value,
+ states: { ...loadingStates },
+ isLoading: isLoading.value,
+ activeStates: activeLoadingStates.value
+ }
+ }
+
+ return {
+ // 状态
+ loadingStates,
+ globalLoading,
+
+ // 计算属性
+ isLoading,
+ activeLoadingStates,
+ activeLoadingCount,
+
+ // 方法
+ setLoading,
+ getLoading,
+ setGlobalLoading,
+ startLoading,
+ stopLoading,
+ resetAll,
+ reset,
+ addState,
+ removeState,
+ withLoading,
+ createTracker,
+ setBatch,
+ getSnapshot
+ }
+}
+
+/**
+ * 创建全局加载状态管理器(单例模式)
+ */
+let globalLoadingStateManager = null
+
+export function useGlobalLoadingState() {
+ if (!globalLoadingStateManager) {
+ globalLoadingStateManager = useLoadingState({
+ global: false,
+ api: false,
+ services: false,
+ tools: false,
+ agents: false,
+ dashboard: false,
+ page: false
+ })
+ }
+ return globalLoadingStateManager
+}
+
+/**
+ * 重置全局加载状态管理器
+ */
+export function resetGlobalLoadingState() {
+ if (globalLoadingStateManager) {
+ globalLoadingStateManager.resetAll()
+ globalLoadingStateManager = null
+ }
+}
+
+/**
+ * 预定义的加载状态键
+ */
+export const LOADING_KEYS = {
+ GLOBAL: 'global',
+ API: 'api',
+ SERVICES: 'services',
+ TOOLS: 'tools',
+ AGENTS: 'agents',
+ DASHBOARD: 'dashboard',
+ PAGE: 'page',
+ ADDING: 'adding',
+ UPDATING: 'updating',
+ DELETING: 'deleting',
+ FETCHING: 'fetching',
+ SUBMITTING: 'submitting',
+ CHECKING: 'checking',
+ HEALTH: 'health'
+}
+
diff --git a/vue/src/main.js b/vue/src/main.js
new file mode 100644
index 00000000..d93aea63
--- /dev/null
+++ b/vue/src/main.js
@@ -0,0 +1,77 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import 'element-plus/theme-chalk/dark/css-vars.css'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import App from './App.vue'
+import router from './router'
+import './styles/index.scss'
+import './styles/theme.scss'
+
+
+// NProgress已移除,保持静默导航体验
+
+const app = createApp(App)
+const pinia = createPinia()
+
+// 注册 Element Plus 图标
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+ app.component(key, component)
+}
+
+// 全局属性(Element Plus 全局配置已通过 app.use(ElementPlus, { ... }) 注入)
+// 移除过时的 $ELEMENT 配置
+
+// 全局错误处理
+app.config.errorHandler = (err, vm, info) => {
+ console.error('Vue Error:', err)
+ console.error('Component:', vm)
+ console.error('Info:', info)
+}
+
+// 全局未捕获的Promise错误处理
+window.addEventListener('unhandledrejection', (event) => {
+ console.error('Unhandled Promise Rejection:', event.reason)
+})
+
+// 全局错误处理
+window.addEventListener('error', (event) => {
+ console.error('Global Error:', event.error)
+})
+
+// 使用插件
+app.use(pinia)
+app.use(router)
+app.use(ElementPlus, {
+ locale: zhCn,
+ size: 'default'
+})
+
+// 挂载应用
+app.mount('#app')
+
+// 🔍 环境变量调试信息(仅开发环境)
+if (import.meta.env.VITE_ENABLE_CONSOLE_LOG === 'true' || import.meta.env.DEV) {
+ console.log('='.repeat(60))
+ console.log('🔍 [MAIN.JS] 环境变量调试信息:')
+ console.log(' - NODE_ENV:', import.meta.env.NODE_ENV)
+ console.log(' - MODE:', import.meta.env.MODE)
+ console.log(' - DEV:', import.meta.env.DEV)
+ console.log(' - PROD:', import.meta.env.PROD)
+ console.log(' - VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL)
+ console.log(' - VITE_API_TIMEOUT:', import.meta.env.VITE_API_TIMEOUT)
+ console.log(' - VITE_APP_TITLE:', import.meta.env.VITE_APP_TITLE)
+ console.log(' - VITE_APP_VERSION:', import.meta.env.VITE_APP_VERSION)
+ console.log(' - VITE_DEV_PORT:', import.meta.env.VITE_DEV_PORT)
+ console.log('='.repeat(60))
+}
+
+// 开发环境启动信息
+if (import.meta.env.DEV) {
+ console.log('🚀 MCPStore Vue Frontend Started')
+ console.log('📡 API Base URL:', import.meta.env.VITE_API_BASE_URL)
+ console.log('🌐 Frontend Port:', import.meta.env.VITE_DEV_PORT || '5177')
+ console.log('📝 Version:', import.meta.env.VITE_APP_VERSION)
+}
diff --git a/vue/src/router/index.js b/vue/src/router/index.js
new file mode 100644
index 00000000..20e13000
--- /dev/null
+++ b/vue/src/router/index.js
@@ -0,0 +1,212 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+// 路由组件懒加载
+const Dashboard = () => import('@/views/Dashboard.vue')
+const ServiceList = () => import('@/views/services/ServiceList.vue')
+const ServiceAdd = () => import('@/views/services/ServiceAdd.vue')
+const ServiceEdit = () => import('@/views/services/ServiceEdit.vue')
+const ServiceDetail = () => import('@/views/services/ServiceDetail.vue')
+
+const ToolList = () => import('@/views/tools/ToolList.vue')
+const ToolExecute = () => import('@/views/tools/ToolExecute.vue')
+const ToolRecords = () => import('@/views/tools/ToolRecords.vue')
+const AgentList = () => import('@/views/agents/AgentList.vue')
+const AgentDetail = () => import('@/views/agents/AgentDetail.vue')
+const AgentServiceAdd = () => import('@/views/agents/ServiceAdd.vue')
+
+const ConfigCenter = () => import('@/views/config/ConfigCenter.vue')
+const CacheSpace = () => import('@/views/CacheSpace.vue')
+const ExternalEmbed = () => import('@/views/ExternalEmbed.vue')
+const ExternalLink = () => import('@/views/ExternalLink.vue')
+
+const routes = [
+ {
+ path: '/',
+ redirect: '/system/dashboard'
+ },
+ {
+ path: '/external/github',
+ name: 'external_github',
+ component: ExternalLink,
+ meta: {
+ title: 'GitHub Project',
+ icon: 'Link',
+ keepAlive: false,
+ url: import.meta.env.VITE_GITHUB_URL || 'https://github.com/whillhill/mcpstore',
+ description: 'Visit the MCPStore project repository on GitHub to view source code, report issues, and contribute.'
+ }
+ },
+ {
+ path: '/external/pypi',
+ name: 'external_pypi',
+ component: ExternalLink,
+ meta: {
+ title: 'PyPI Package',
+ icon: 'Link',
+ keepAlive: false,
+ url: import.meta.env.VITE_PYPI_URL || 'https://pypi.org/project/mcpstore',
+ description: 'View the MCPStore package on PyPI for installation instructions and version history.'
+ }
+ },
+ {
+ path: '/docs',
+ name: 'system_docs_index',
+ component: ExternalEmbed,
+ meta: {
+ title: '文档中心',
+ icon: 'Reading',
+ keepAlive: true,
+ url: import.meta.env.VITE_DOCS_URL || 'https://doc.mcpstore.wiki/'
+ }
+ },
+ // system
+ {
+ path: '/system/dashboard',
+ name: 'system_dashboard',
+ component: Dashboard,
+ meta: {
+ title: '仪表板',
+ icon: 'Monitor',
+ keepAlive: true
+ }
+ },
+ // for_store - services
+ {
+ path: '/for_store/list_services',
+ name: 'for_store_list_services',
+ component: ServiceList,
+ meta: { title: '服务列表', icon: 'Connection', keepAlive: true }
+ },
+ {
+ path: '/for_store/add_service',
+ name: 'for_store_add_service',
+ component: ServiceAdd,
+ meta: { title: '添加服务', icon: 'Plus', hidden: true }
+ },
+ {
+ path: '/for_store/update_service/:serviceName',
+ name: 'for_store_update_service',
+ component: ServiceEdit,
+ meta: { title: '编辑服务', icon: 'Edit', hidden: true }
+ },
+ {
+ path: '/for_store/service_info/:serviceName',
+ name: 'for_store_service_info',
+ component: ServiceDetail,
+ meta: { title: '服务详情', icon: 'View', hidden: true }
+ },
+ // for_store - tools
+ {
+ path: '/for_store/list_tools',
+ name: 'for_store_list_tools',
+ component: ToolList,
+ meta: { title: '工具列表', icon: 'Tools', keepAlive: true }
+ },
+ {
+ path: '/for_store/call_tool',
+ name: 'for_store_call_tool',
+ component: ToolExecute,
+ meta: { title: '工具执行', icon: 'VideoPlay', hidden: true }
+ },
+ {
+ path: '/for_store/tool_records',
+ name: 'for_store_tool_records',
+ component: ToolRecords,
+ meta: { title: '工具记录', icon: 'Document', keepAlive: true }
+ },
+ // for_store - agents
+ {
+ path: '/for_store/list_agents',
+ name: 'for_store_list_agents',
+ component: AgentList,
+ meta: { title: 'Agent列表', icon: 'User', keepAlive: true }
+ },
+ {
+ path: '/for_store/agent_detail/:id',
+ name: 'for_store_agent_detail',
+ component: AgentDetail,
+ meta: { title: 'Agent详情', icon: 'UserFilled', hidden: true }
+ },
+ // for_agent - add_service (保留功能入口)
+ {
+ path: '/for_agent/:agent_id/add_service',
+ name: 'for_agent_add_service',
+ component: AgentServiceAdd,
+ meta: { title: '为Agent添加服务', icon: 'Plus', hidden: true }
+ },
+ // for_store - config/cache
+ {
+ path: '/for_store/show_config',
+ name: 'for_store_show_config',
+ component: ConfigCenter,
+ meta: { title: '配置中心', icon: 'Setting', keepAlive: true }
+ },
+ {
+ path: '/for_store/show_cache',
+ name: 'for_store_show_cache',
+ component: CacheSpace,
+ meta: { title: '缓存空间', icon: 'Coin', keepAlive: true }
+ },
+ // Redirects from old paths
+ { path: '/dashboard', redirect: '/system/dashboard', meta: { hidden: true } },
+ { path: '/services', redirect: '/for_store/list_services', meta: { hidden: true } },
+ { path: '/services/add', redirect: '/for_store/add_service', meta: { hidden: true } },
+ { path: '/services/edit/:serviceName', redirect: '/for_store/update_service/:serviceName', meta: { hidden: true } },
+ { path: '/services/detail/:serviceName', redirect: '/for_store/service_info/:serviceName', meta: { hidden: true } },
+ { path: '/tools', redirect: '/for_store/list_tools', meta: { hidden: true } },
+ { path: '/tools/execute', redirect: '/for_store/call_tool', meta: { hidden: true } },
+ { path: '/tools/records', redirect: '/for_store/tool_records', meta: { hidden: true } },
+ { path: '/agents', redirect: '/for_store/list_agents', meta: { hidden: true } },
+ { path: '/agents/:id/detail', redirect: '/for_store/list_agents', meta: { hidden: true } },
+ { path: '/agents/service-add', redirect: '/for_store/add_service', meta: { hidden: true } },
+ { path: '/config', redirect: '/for_store/show_config', meta: { hidden: true } },
+ { path: '/cache', redirect: '/for_store/show_cache', meta: { hidden: true } },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'system_not_found',
+ component: () => import('@/views/NotFound.vue'),
+ meta: {
+ title: '页面未找到',
+ hidden: true
+ }
+ }
+]
+
+const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL || '/'),
+ routes,
+ scrollBehavior(to, from, savedPosition) {
+ if (savedPosition) {
+ return savedPosition
+ } else {
+ return { top: 0 }
+ }
+ }
+})
+
+// 全局前置守卫
+router.beforeEach((to, from, next) => {
+ // 不启动NProgress,保持静默导航
+
+ // 设置页面标题
+ const appTitle = import.meta.env.VITE_APP_TITLE || 'MCPStore 管理面板'
+ if (to.meta.title) {
+ document.title = `${to.meta.title} - ${appTitle}`
+ } else {
+ document.title = appTitle
+ }
+
+ next()
+})
+
+// 全局后置钩子
+router.afterEach((to, from) => {
+ // 静默导航,不使用NProgress
+})
+
+// 路由错误处理
+router.onError((error) => {
+ console.error('Router Error:', error)
+})
+
+export default router
diff --git a/vue/src/stores/agents.js b/vue/src/stores/agents.js
new file mode 100644
index 00000000..1a946641
--- /dev/null
+++ b/vue/src/stores/agents.js
@@ -0,0 +1,374 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { api } from '@/api'
+
+export const useAgentsStore = defineStore('agents', () => {
+ // 状态
+ const agents = ref([])
+ const currentAgent = ref(null)
+ const loading = ref(false)
+ const lastUpdateTime = ref(null)
+
+ // Agent统计
+ const stats = ref({
+ total: 0,
+ active: 0,
+ inactive: 0,
+ partial: 0,
+ error: 0,
+ totalServices: 0,
+ totalTools: 0
+ })
+
+ // 计算属性
+ const agentsByStatus = computed(() => {
+ return agents.value.reduce((acc, agent) => {
+ const status = agent.status || 'inactive'
+ if (!acc[status]) acc[status] = []
+ acc[status].push(agent)
+ return acc
+ }, {})
+ })
+
+ const activeAgents = computed(() => {
+ return agents.value.filter(a => a.status === 'active')
+ })
+
+ const inactiveAgents = computed(() => {
+ return agents.value.filter(a => a.status === 'inactive')
+ })
+
+ const partialAgents = computed(() => {
+ return agents.value.filter(a => a.status === 'partial')
+ })
+
+ const errorAgents = computed(() => {
+ return agents.value.filter(a => a.status === 'error')
+ })
+
+ // === 核心数据获取 ===
+
+ const fetchAgents = async () => {
+ loading.value = true
+ try {
+ const response = await api.agent.getAgentsList()
+
+ // 新接口返回格式: { success: true, data: { agents: [...], summary: {...} } }
+ const agentsData = response.data?.data?.agents || []
+
+ if (!Array.isArray(agentsData)) {
+ console.error('Agents数据格式错误:', agentsData)
+ agents.value = []
+ } else {
+ // 转换新的数据结构(使用文档提供的字段)
+ agents.value = agentsData.map(agent => ({
+ id: agent.agent_id,
+ name: agent.agent_id,
+ description: `${agent.service_count || 0} 个服务 / ${agent.tool_count || 0} 个工具`,
+ status: getAgentStatus(agent),
+ services: agent.service_count || 0,
+ tools: agent.tool_count || 0,
+ healthy_services: agent.healthy_services || 0,
+ unhealthy_services: agent.unhealthy_services || 0,
+ is_active: agent.is_active === true,
+ client_ids: Array.isArray(agent.client_ids) ? agent.client_ids : [],
+ last_activity: agent.last_activity || null,
+ created_at: new Date().toISOString()
+ }))
+ }
+
+ // 使用后端 summary 更新统计(若提供)
+ const summary = response.data?.data?.summary
+ if (summary && typeof summary === 'object') {
+ stats.value.total = summary.total_agents ?? agents.value.length
+ // healthy_agents: 至少有一个健康服务
+ stats.value.active = summary.healthy_agents ?? agents.value.filter(a => a.is_active).length
+ stats.value.inactive = summary.unhealthy_agents != null
+ ? (summary.total_agents - summary.healthy_agents)
+ : agents.value.filter(a => a.status === 'inactive').length
+ // 估算 partial:有服务但不是 active
+ stats.value.partial = agents.value.filter(a => a.services > 0 && a.status === 'partial').length
+ stats.value.error = 0
+ stats.value.totalServices = summary.total_services ?? agents.value.reduce((sum, a) => sum + (a.services || 0), 0)
+ stats.value.totalTools = summary.total_tools ?? agents.value.reduce((sum, a) => sum + (a.tools || 0), 0)
+ } else {
+ updateStats()
+ }
+ lastUpdateTime.value = new Date()
+ return agents.value
+ } catch (error) {
+ console.error('获取Agent列表失败:', error)
+ agents.value = []
+ updateStats()
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 根据文档新增字段确定 Agent 状态
+ const getAgentStatus = (agent) => {
+ const serviceCount = agent.service_count ?? agent.services ?? 0
+ const toolCount = agent.tool_count ?? agent.tools ?? 0
+ const healthyCount = agent.healthy_services ?? 0
+ const unhealthyCount = agent.unhealthy_services ?? 0
+ const isActive = agent.is_active === true
+
+ if (serviceCount === 0) return 'inactive'
+ if (isActive || healthyCount === serviceCount) return 'active'
+ if (healthyCount > 0 || (serviceCount > 0 && unhealthyCount > 0)) return 'partial'
+ if (serviceCount > 0 && toolCount === 0) return 'partial'
+ return 'inactive'
+ }
+
+ // 数据归一化
+ const normalizeServicesPayload = (payload) => {
+ if (Array.isArray(payload?.services)) return payload.services
+ if (Array.isArray(payload)) return payload
+ return []
+ }
+
+ const normalizeToolsPayload = (payload) => {
+ if (Array.isArray(payload?.tools)) return payload.tools
+ if (Array.isArray(payload)) return payload
+ return []
+ }
+
+ const buildAgentStats = (servicesData = [], toolsData = []) => {
+ const servicesList = normalizeServicesPayload(servicesData)
+ const toolsList = normalizeToolsPayload(toolsData)
+
+ const healthyServices = servicesList.filter(
+ svc => svc.is_active === true || svc.status === 'active' || svc.status === 'healthy'
+ ).length
+ const byTransport = servicesList.reduce((acc, svc) => {
+ const transport = svc.transport || (svc.command ? 'stdio' : 'http') || 'unknown'
+ acc[transport] = (acc[transport] || 0) + 1
+ return acc
+ }, {})
+ const totalToolExecutions = toolsList.reduce(
+ (sum, tool) => sum + (tool.total_executions || tool.execution_count || 0),
+ 0
+ )
+
+ return {
+ services: servicesList.length,
+ tools: toolsList.length,
+ healthy_services: healthyServices,
+ unhealthy_services: Math.max(servicesList.length - healthyServices, 0),
+ total_tool_executions: totalToolExecutions,
+ orchestrator_status: 'unknown',
+ by_transport: byTransport
+ }
+ }
+
+ // === Agent服务管理 ===
+
+ const getAgentServices = async (agentId) => {
+ // Force HMR update
+ try {
+ console.log('🔍 [DEBUG] 获取Agent服务列表:', agentId)
+ const services = await api.agent.listServices(agentId)
+ const normalized = normalizeServicesPayload(services)
+ console.log('🔍 [DEBUG] Agent服务API响应:', normalized)
+ return normalized
+ } catch (error) {
+ console.error('获取Agent服务列表失败:', error)
+ throw error
+ }
+ }
+
+ const getAgentTools = async (agentId) => {
+ try {
+ console.log('🔍 [DEBUG] 获取Agent工具列表:', agentId)
+ const tools = await api.agent.listTools(agentId)
+ const normalized = normalizeToolsPayload(tools)
+ console.log('🔍 [DEBUG] Agent工具API响应:', normalized)
+ return normalized
+ } catch (error) {
+ console.error('获取Agent工具列表失败:', error)
+ throw error
+ }
+ }
+
+ const getAgentStats = async (agentId, options = {}) => {
+ try {
+ console.log('🔍 [DEBUG] 获取Agent统计信息:', agentId)
+ const servicesData = options.services ?? await getAgentServices(agentId)
+ const toolsData = options.tools ?? await getAgentTools(agentId)
+ const stats = buildAgentStats(servicesData, toolsData)
+ console.log('🔍 [DEBUG] Agent统计API响应:', stats)
+ return stats
+ } catch (error) {
+ console.error('获取Agent统计信息失败:', error)
+ throw error
+ }
+ }
+
+ const addService = async (agentId, serviceConfig) => {
+ try {
+ const response = await api.agent.addService(agentId, serviceConfig)
+ if (response.data.success) {
+ await fetchAgents() // 重新获取列表以更新统计
+ return { success: true, data: response.data }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const deleteService = async (agentId, serviceName) => {
+ try {
+ const response = await api.agent.deleteService(agentId, serviceName)
+ if (response.data.success) {
+ await fetchAgents() // 重新获取列表以更新统计
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const updateService = async (agentId, serviceName, config) => {
+ try {
+ const response = await api.agent.updateService(agentId, serviceName, config)
+ if (response.data.success) {
+ await fetchAgents() // 重新获取列表以更新统计
+ return { success: true, data: response.data }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const restartService = async (agentId, serviceName) => {
+ try {
+ const response = await api.agent.restartService(agentId, serviceName)
+ return response.data
+ } catch (error) {
+ console.error('重启服务失败:', error)
+ throw error
+ }
+ }
+
+ const useTool = async (agentId, toolName, args) => {
+ try {
+ const response = await api.agent.callTool(agentId, toolName, args)
+ return response.data
+ } catch (error) {
+ console.error('使用工具失败:', error)
+ throw error
+ }
+ }
+
+ const checkServices = async (agentId) => {
+ try {
+ const response = await api.agent.checkServices(agentId)
+ return response.data
+ } catch (error) {
+ console.error('检查服务健康状态失败:', error)
+ throw error
+ }
+ }
+
+ const resetAgentConfig = async (agentId) => {
+ try {
+ const response = await api.agent.resetConfig(agentId)
+ if (response.data.success) {
+ await fetchAgents() // 重新获取列表
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ // === 工具函数 ===
+
+ const updateStats = () => {
+ stats.value.total = agents.value.length
+ stats.value.active = agents.value.filter(a => a.status === 'active').length
+ stats.value.inactive = agents.value.filter(a => a.status === 'inactive').length
+ stats.value.partial = agents.value.filter(a => a.status === 'partial').length
+ stats.value.error = agents.value.filter(a => a.status === 'error').length
+ stats.value.totalServices = agents.value.reduce((sum, a) => sum + (a.services || 0), 0)
+ stats.value.totalTools = agents.value.reduce((sum, a) => sum + (a.tools || 0), 0)
+ }
+
+ const setCurrentAgent = (agent) => {
+ currentAgent.value = agent
+ }
+
+ const getAgentById = (id) => {
+ return agents.value.find(a => a.id === id)
+ }
+
+ const searchAgents = (query) => {
+ if (!query) return agents.value
+
+ const lowerQuery = query.toLowerCase()
+ return agents.value.filter(agent =>
+ agent.name.toLowerCase().includes(lowerQuery) ||
+ agent.id.toLowerCase().includes(lowerQuery) ||
+ (agent.description && agent.description.toLowerCase().includes(lowerQuery))
+ )
+ }
+
+ const resetStore = () => {
+ agents.value = []
+ currentAgent.value = null
+ stats.value = {
+ total: 0,
+ active: 0,
+ inactive: 0,
+ partial: 0,
+ error: 0,
+ totalServices: 0,
+ totalTools: 0
+ }
+ lastUpdateTime.value = null
+ }
+
+ return {
+ // 状态
+ agents,
+ currentAgent,
+ loading,
+ lastUpdateTime,
+ stats,
+
+ // 计算属性
+ agentsByStatus,
+ activeAgents,
+ inactiveAgents,
+ partialAgents,
+ errorAgents,
+
+ // 方法
+ fetchAgents,
+ getAgentServices,
+ getAgentTools,
+ getAgentStats,
+ addService,
+ deleteService,
+ updateService,
+ restartService,
+ useTool,
+ checkServices,
+ resetAgentConfig,
+ updateStats,
+ buildAgentStats,
+ setCurrentAgent,
+ getAgentById,
+ searchAgents,
+ resetStore
+ }
+})
diff --git a/vue/src/stores/app.js b/vue/src/stores/app.js
new file mode 100644
index 00000000..0b6ddd76
--- /dev/null
+++ b/vue/src/stores/app.js
@@ -0,0 +1,500 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { useErrorHandler, useLoadingState } from '@/composables'
+
+export const useAppStore = defineStore('app', () => {
+ // 使用 composables
+ const errorHandler = useErrorHandler({ source: 'app-store', maxErrors: 200 })
+ const loadingState = useLoadingState({
+ global: false,
+ api: false,
+ tools: false,
+ services: false,
+ dashboard: false
+ })
+
+ // 状态
+ const isCollapse = ref(false)
+ const theme = ref('light')
+ const language = ref('zh-CN')
+ const loading = ref(false)
+
+ // 设备信息
+ const device = ref('desktop')
+ const isMobile = computed(() => device.value === 'mobile')
+ const isTablet = computed(() => device.value === 'tablet')
+ const isDesktop = computed(() => device.value === 'desktop')
+
+ // 布局配置
+ const layoutConfig = ref({
+ sidebarWidth: 250,
+ sidebarCollapsedWidth: 64,
+ headerHeight: 60,
+ footerHeight: 50,
+ showFooter: false,
+ showBreadcrumb: true,
+ showTabs: false
+ })
+
+ // 主题配置
+ const themeConfig = ref({
+ primaryColor: '#409EFF',
+ successColor: '#67C23A',
+ warningColor: '#E6A23C',
+ dangerColor: '#F56C6C',
+ infoColor: '#909399'
+ })
+
+ // 用户偏好设置
+ const userPreferences = ref({
+ autoRefresh: false, // 暂时禁用自动刷新
+ refreshInterval: 60000, // 增加到60秒
+ showNotifications: true,
+ soundEnabled: false,
+ animationEnabled: true,
+ dashboardLayout: 'default', // 'default' | 'compact' | 'detailed'
+ toolDisplayMode: 'grid', // 'grid' | 'list'
+ pageSize: 20
+ })
+
+ // 应用配置
+ const config = ref({
+ apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
+ apiTimeout: parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000,
+ appTitle: import.meta.env.VITE_APP_TITLE || 'MCPStore',
+ version: import.meta.env.VITE_APP_VERSION || '1.4.1',
+ environment: import.meta.env.MODE || 'development',
+ githubUrl: import.meta.env.VITE_GITHUB_URL || '',
+ pypiUrl: import.meta.env.VITE_PYPI_URL || '',
+ docsUrl: import.meta.env.VITE_DOCS_URL || ''
+ })
+
+ // 通知状态
+ const notifications = ref([])
+ const unreadCount = ref(0)
+
+ // 应用状态
+ const appState = ref({
+ initialized: false,
+ connected: true,
+ lastActivity: Date.now(),
+ sessionId: null,
+ uptime: 0
+ })
+
+ // 性能监控
+ const performance = ref({
+ apiResponseTimes: [],
+ memoryUsage: 0,
+ renderTime: 0,
+ errorCount: 0
+ })
+
+ // 计算属性
+ const isDark = computed(() => theme.value === 'dark')
+ const sidebarWidth = computed(() =>
+ isCollapse.value ? layoutConfig.value.sidebarCollapsedWidth : layoutConfig.value.sidebarWidth
+ )
+
+ // 是否有任何加载状态(使用 composable)
+ const isLoading = computed(() => loadingState.isLoading.value)
+
+ // 是否有错误(使用 composable)
+ const hasErrors = computed(() => errorHandler.hasErrors.value)
+
+ // 是否为开发环境
+ const isDevelopment = computed(() => {
+ return config.value.environment === 'development'
+ })
+
+ // 应用是否就绪
+ const isReady = computed(() => {
+ return appState.value.initialized && appState.value.connected && !isLoading.value
+ })
+
+ // 最近的错误(使用 composable)
+ const recentErrors = computed(() => errorHandler.recentErrors.value)
+
+ // 未读通知数量
+ const hasUnreadNotifications = computed(() => {
+ return unreadCount.value > 0
+ })
+
+ // 方法
+ const setCollapse = (value) => {
+ isCollapse.value = value
+ localStorage.setItem('mcpstore-collapse', value.toString())
+ }
+
+ const setTheme = (value) => {
+ theme.value = value
+ localStorage.setItem('mcpstore-theme', value)
+
+ // 更新CSS变量
+ const root = document.documentElement
+ if (value === 'dark') {
+ root.classList.add('dark')
+ } else {
+ root.classList.remove('dark')
+ }
+ }
+
+ const setLanguage = (value) => {
+ language.value = value
+ localStorage.setItem('mcpstore-language', value)
+ }
+
+ const setDevice = (value) => {
+ device.value = value
+
+ // 移动端自动收起侧边栏
+ if (value === 'mobile') {
+ setCollapse(true)
+ }
+ }
+
+ const setLoading = (value) => {
+ loading.value = value
+ }
+
+ // 设置特定类型的加载状态(使用 composable)
+ const setLoadingState = (type, status) => {
+ loadingState.setLoading(type, status)
+ }
+
+ // 设置全局加载状态(使用 composable)
+ const setGlobalLoading = (status) => {
+ loadingState.setLoading('global', status)
+ }
+
+ // 添加错误(使用 composable)
+ const addError = (error) => {
+ const errorObj = errorHandler.addError(error)
+ performance.value.errorCount++
+ return errorObj
+ }
+
+ // 清除错误(使用 composable)
+ const clearErrors = () => {
+ errorHandler.clearErrors()
+ }
+
+ // 移除特定错误(使用 composable)
+ const removeError = (errorId) => {
+ errorHandler.removeError(errorId)
+ }
+
+ // setPageLoading已移除,不再需要全局页面loading
+
+ const updateLayoutConfig = (config) => {
+ layoutConfig.value = { ...layoutConfig.value, ...config }
+ localStorage.setItem('mcpstore-layout', JSON.stringify(layoutConfig.value))
+ }
+
+ const updateThemeConfig = (config) => {
+ themeConfig.value = { ...themeConfig.value, ...config }
+ localStorage.setItem('mcpstore-theme-config', JSON.stringify(themeConfig.value))
+
+ // 更新CSS变量
+ const root = document.documentElement
+ Object.entries(config).forEach(([key, value]) => {
+ const cssVar = `--el-color-${key.replace('Color', '')}`
+ root.style.setProperty(cssVar, value)
+ })
+ }
+
+ const updateUserPreferences = (preferences) => {
+ userPreferences.value = { ...userPreferences.value, ...preferences }
+ localStorage.setItem('mcpstore-preferences', JSON.stringify(userPreferences.value))
+ }
+
+ // 添加通知
+ const addNotification = (notification) => {
+ const notificationObj = {
+ id: Date.now(),
+ title: notification.title,
+ message: notification.message,
+ type: notification.type || 'info', // 'success' | 'warning' | 'error' | 'info'
+ timestamp: new Date().toISOString(),
+ read: false,
+ persistent: notification.persistent || false
+ }
+
+ notifications.value.unshift(notificationObj)
+ unreadCount.value++
+
+ // 限制通知数量
+ if (notifications.value.length > 50) {
+ notifications.value = notifications.value.slice(0, 50)
+ }
+ }
+
+ // 标记通知为已读
+ const markNotificationRead = (notificationId) => {
+ const notification = notifications.value.find(n => n.id === notificationId)
+ if (notification && !notification.read) {
+ notification.read = true
+ unreadCount.value = Math.max(0, unreadCount.value - 1)
+ }
+ }
+
+ // 清除所有通知
+ const clearNotifications = () => {
+ notifications.value = []
+ unreadCount.value = 0
+ }
+
+ // 更新连接状态
+ const setConnectionStatus = (connected) => {
+ appState.value.connected = connected
+ if (!connected) {
+ addNotification({
+ title: '连接断开',
+ message: '与服务器的连接已断开,正在尝试重连...',
+ type: 'warning',
+ persistent: true
+ })
+ }
+ }
+
+ // 记录API响应时间
+ const recordApiResponseTime = (time) => {
+ performance.value.apiResponseTimes.push({
+ time,
+ timestamp: Date.now()
+ })
+
+ // 只保留最近100次记录
+ if (performance.value.apiResponseTimes.length > 100) {
+ performance.value.apiResponseTimes = performance.value.apiResponseTimes.slice(-100)
+ }
+ }
+
+ // 更新活动时间
+ const updateActivity = () => {
+ appState.value.lastActivity = Date.now()
+ }
+
+ const initializeApp = async () => {
+ try {
+ setGlobalLoading(true)
+
+ // 从localStorage恢复状态
+ const savedCollapse = localStorage.getItem('mcpstore-collapse')
+ if (savedCollapse !== null) {
+ isCollapse.value = savedCollapse === 'true'
+ }
+
+ const savedTheme = localStorage.getItem('mcpstore-theme')
+ if (savedTheme) {
+ setTheme(savedTheme)
+ }
+
+ const savedLanguage = localStorage.getItem('mcpstore-language')
+ if (savedLanguage) {
+ language.value = savedLanguage
+ }
+
+ const savedLayout = localStorage.getItem('mcpstore-layout')
+ if (savedLayout) {
+ try {
+ layoutConfig.value = { ...layoutConfig.value, ...JSON.parse(savedLayout) }
+ } catch (e) {
+ console.warn('Failed to parse saved layout config:', e)
+ }
+ }
+
+ const savedThemeConfig = localStorage.getItem('mcpstore-theme-config')
+ if (savedThemeConfig) {
+ try {
+ updateThemeConfig(JSON.parse(savedThemeConfig))
+ } catch (e) {
+ console.warn('Failed to parse saved theme config:', e)
+ }
+ }
+
+ const savedPreferences = localStorage.getItem('mcpstore-preferences')
+ if (savedPreferences) {
+ try {
+ userPreferences.value = { ...userPreferences.value, ...JSON.parse(savedPreferences) }
+ } catch (e) {
+ console.warn('Failed to parse saved preferences:', e)
+ }
+ }
+
+ // 生成会话ID
+ appState.value.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+
+ // 检测设备类型
+ detectDevice()
+
+ // 监听窗口大小变化
+ window.addEventListener('resize', detectDevice)
+
+ // 标记为已初始化
+ appState.value.initialized = true
+ appState.value.lastActivity = Date.now()
+
+ console.log('🚀 App initialized successfully')
+
+ } catch (error) {
+ addError({
+ message: 'Failed to initialize app',
+ source: 'app-store',
+ type: 'initialization',
+ ...error
+ })
+ } finally {
+ setGlobalLoading(false)
+ }
+ }
+
+ const detectDevice = () => {
+ const width = window.innerWidth
+ if (width < 768) {
+ setDevice('mobile')
+ } else if (width < 1024) {
+ setDevice('tablet')
+ } else {
+ setDevice('desktop')
+ }
+ }
+
+ const saveSettings = () => {
+ // 保存所有设置到localStorage
+ localStorage.setItem('mcpstore-collapse', isCollapse.value.toString())
+ localStorage.setItem('mcpstore-theme', theme.value)
+ localStorage.setItem('mcpstore-language', language.value)
+ localStorage.setItem('mcpstore-layout', JSON.stringify(layoutConfig.value))
+ localStorage.setItem('mcpstore-theme-config', JSON.stringify(themeConfig.value))
+ localStorage.setItem('mcpstore-preferences', JSON.stringify(userPreferences.value))
+ console.log('✅ Settings saved to localStorage')
+ }
+
+ const resetSettings = () => {
+ // 重置为默认值
+ isCollapse.value = false
+ theme.value = 'light'
+ language.value = 'zh-CN'
+ layoutConfig.value = {
+ sidebarWidth: 250,
+ sidebarCollapsedWidth: 64,
+ headerHeight: 60,
+ footerHeight: 50,
+ showFooter: false,
+ showBreadcrumb: true,
+ showTabs: false
+ }
+ themeConfig.value = {
+ primaryColor: '#409EFF',
+ successColor: '#67C23A',
+ warningColor: '#E6A23C',
+ dangerColor: '#F56C6C',
+ infoColor: '#909399'
+ }
+ userPreferences.value = {
+ autoRefresh: false, // 默认禁用自动刷新
+ refreshInterval: 60000, // 60秒
+ showNotifications: true,
+ soundEnabled: false,
+ animationEnabled: true,
+ dashboardLayout: 'default',
+ toolDisplayMode: 'grid',
+ pageSize: 20
+ }
+
+ // 清除所有状态
+ errorHandler.clearErrors()
+ notifications.value = []
+ unreadCount.value = 0
+
+ // 重置加载状态
+ loadingState.resetAll()
+
+ // 重置性能数据
+ performance.value = {
+ apiResponseTimes: [],
+ memoryUsage: 0,
+ renderTime: 0,
+ errorCount: 0
+ }
+
+ // 清除localStorage
+ localStorage.removeItem('mcpstore-collapse')
+ localStorage.removeItem('mcpstore-theme')
+ localStorage.removeItem('mcpstore-language')
+ localStorage.removeItem('mcpstore-layout')
+ localStorage.removeItem('mcpstore-theme-config')
+ localStorage.removeItem('mcpstore-preferences')
+
+ // 重新应用设置
+ setTheme('light')
+
+ console.log('🔄 App settings reset')
+ }
+
+ return {
+ // 原有状态
+ isCollapse,
+ theme,
+ language,
+ loading,
+ device,
+ layoutConfig,
+ themeConfig,
+ userPreferences,
+
+ // 新增状态
+ config,
+ notifications,
+ unreadCount,
+ appState,
+ performance,
+
+ // Composable 实例(用于访问错误和加载状态)
+ errorHandler,
+ loadingState,
+
+ // 原有计算属性
+ isDark,
+ isMobile,
+ isTablet,
+ isDesktop,
+ sidebarWidth,
+
+ // 新增计算属性
+ isLoading,
+ hasErrors,
+ isDevelopment,
+ isReady,
+ recentErrors,
+ hasUnreadNotifications,
+
+ // 原有方法
+ setCollapse,
+ setTheme,
+ setLanguage,
+ setDevice,
+ setLoading,
+ updateLayoutConfig,
+ updateThemeConfig,
+ updateUserPreferences,
+ initializeApp,
+ detectDevice,
+ saveSettings,
+ resetSettings,
+
+ // 新增方法
+ setLoadingState,
+ setGlobalLoading,
+ addError,
+ clearErrors,
+ removeError,
+ addNotification,
+ markNotificationRead,
+ clearNotifications,
+ setConnectionStatus,
+ recordApiResponseTime,
+ updateActivity
+ }
+})
diff --git a/vue/src/stores/services.js b/vue/src/stores/services.js
new file mode 100644
index 00000000..9c7c9b92
--- /dev/null
+++ b/vue/src/stores/services.js
@@ -0,0 +1,550 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { api } from '@/api'
+import { useAppStore } from './app'
+import { useErrorHandler, useLoadingState, LOADING_KEYS } from '@/composables'
+import { logger } from '@/utils/logger'
+
+export const useServicesStore = defineStore('services', () => {
+ const appStore = useAppStore()
+
+ // 使用 composables
+ const errorHandler = useErrorHandler({ source: 'services-store' })
+ const loadingState = useLoadingState({
+ services: false,
+ health: false,
+ adding: false,
+ removing: false,
+ updating: false,
+ checking: false
+ })
+
+ // 状态
+ const services = ref([])
+ const currentService = ref(null)
+ const loading = ref(false)
+ const lastUpdateTime = ref(null)
+
+ // 服务统计
+ const stats = ref({
+ total: 0,
+ running: 0,
+ stopped: 0,
+ error: 0,
+ local: 0,
+ remote: 0
+ })
+
+ // 新增状态
+ const serviceHealth = ref({}) // service_id -> health_info
+ const connectionStatus = ref({}) // service_id -> connection_status
+ const serviceMetrics = ref({}) // service_id -> metrics
+
+ // 服务配置
+ const serviceConfig = ref({
+ autoRefresh: false, // 暂时禁用自动刷新
+ refreshInterval: 60000, // 增加到60秒
+ healthCheckInterval: 120000, // 增加到2分钟
+ maxRetries: 2, // 减少重试次数
+ timeout: 15000 // 增加超时时间
+ })
+
+ // 计算属性
+ const servicesByStatus = computed(() => {
+ return services.value.reduce((acc, service) => {
+ const status = service.status || 'unknown'
+ if (!acc[status]) acc[status] = []
+ acc[status].push(service)
+ return acc
+ }, {})
+ })
+
+ const runningServices = computed(() => {
+ return services.value.filter(s => s.status === 'healthy' || s.status === 'running')
+ })
+
+ const localServices = computed(() => {
+ return services.value.filter(s => s.command)
+ })
+
+ const remoteServices = computed(() => {
+ return services.value.filter(s => s.url)
+ })
+
+ const healthyServices = computed(() => {
+ return services.value.filter(s => s.status === 'healthy')
+ })
+
+ const unhealthyServices = computed(() => {
+ return services.value.filter(s => s.status !== 'healthy')
+ })
+
+ // 新增计算属性
+ const failedServices = computed(() => {
+ return services.value.filter(s => s.status === 'error' || s.status === 'unhealthy')
+ })
+
+ const unknownServices = computed(() => {
+ return services.value.filter(s => !s.status || s.status === 'unknown')
+ })
+
+ // 是否有任何加载状态
+ const isLoading = computed(() => {
+ return loadingState.isLoading.value || loading.value
+ })
+
+ // 是否有错误(使用 composable)
+ const hasErrors = computed(() => errorHandler.hasErrors.value)
+
+ // 最近的错误(使用 composable)
+ const recentErrors = computed(() => errorHandler.recentErrors.value)
+
+ // 活跃的服务(已连接且健康)
+ const activeServices = computed(() => {
+ return services.value.filter(service => {
+ const health = serviceHealth.value[service.name]
+ const connection = connectionStatus.value[service.name]
+ return service.status === 'healthy' && (!connection || connection.connected !== false)
+ })
+ })
+
+ // 服务健康率
+ const healthRate = computed(() => {
+ const total = services.value.length
+ const healthy = healthyServices.value.length
+ return total > 0 ? (healthy / total * 100).toFixed(1) : 0
+ })
+
+ // 新增方法(使用 composables)
+ const setLoadingState = (type, status) => {
+ loadingState.setLoading(type, status)
+ }
+
+ const addError = (error) => {
+ const errorObj = errorHandler.addError(error)
+
+ // 同时添加到应用级错误
+ if (appStore) {
+ appStore.addError(errorObj)
+ }
+
+ return errorObj
+ }
+
+ const clearErrors = () => {
+ errorHandler.clearErrors()
+ }
+
+ // 方法
+ const fetchServices = async (force = false) => {
+ if ((loading.value || loadingState.getLoading('services')) && !force) return
+
+ loading.value = true
+ setLoadingState('services', true)
+
+ try {
+ appStore?.setLoadingState('services', true)
+
+ const servicesArr = await api.store.listServices()
+
+ // 处理数据结构,确保必要字段存在
+ services.value = (Array.isArray(servicesArr) ? servicesArr : []).map(service => ({
+ ...service,
+ is_active: service.is_active !== undefined ? service.is_active : (service.state_metadata !== null),
+ consecutive_successes: service.consecutive_successes || 0,
+ consecutive_failures: service.consecutive_failures || 0,
+ last_ping_time: service.last_ping_time || null,
+ error_message: service.error_message || null,
+ reconnect_attempts: service.reconnect_attempts || 0,
+ state_entered_time: service.state_entered_time || null,
+ activating: false,
+ restarting: false
+ }))
+
+ updateStats()
+ lastUpdateTime.value = new Date()
+
+ return services.value
+ } catch (error) {
+ console.error('获取服务列表失败:', error)
+ addError({
+ message: `获取服务列表失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchServices'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('services', false)
+ appStore?.setLoadingState('services', false)
+ }
+ }
+
+ const addService = async (serviceData) => {
+ try {
+ setLoadingState('adding', true)
+ appStore?.setLoadingState('services', true)
+
+ const response = await api.store.addService(serviceData)
+ if (response.data.success) {
+ await fetchServices(true) // 强制重新获取列表
+
+ appStore?.addNotification({
+ title: '服务添加成功',
+ message: `服务 "${serviceData.name || serviceData.command}" 已成功添加`,
+ type: 'success'
+ })
+
+ return { success: true, data: response.data }
+ } else {
+ const errorMsg = response.data.message || '添加服务失败'
+ addError({
+ message: errorMsg,
+ type: 'add-error',
+ source: 'addService'
+ })
+ return { success: false, error: errorMsg }
+ }
+ } catch (error) {
+ const errorMsg = error.message || '添加服务失败'
+ addError({
+ message: errorMsg,
+ type: 'add-error',
+ source: 'addService'
+ })
+ return { success: false, error: errorMsg }
+ } finally {
+ setLoadingState('adding', false)
+ appStore?.setLoadingState('services', false)
+ }
+ }
+
+ const deleteService = async (serviceName) => {
+ try {
+ setLoadingState('removing', true)
+ appStore?.setLoadingState('services', true)
+
+ const response = await api.store.deleteService(serviceName)
+ if (response.data.success) {
+ // 从本地状态中移除
+ const index = services.value.findIndex(s => s.name === serviceName)
+ if (index > -1) {
+ services.value.splice(index, 1)
+
+ // 清理相关状态
+ delete serviceHealth.value[serviceName]
+ delete connectionStatus.value[serviceName]
+ delete serviceMetrics.value[serviceName]
+ }
+
+ updateStats()
+
+ appStore?.addNotification({
+ title: '服务移除成功',
+ message: `服务 "${serviceName}" 已成功移除`,
+ type: 'success'
+ })
+
+ return { success: true }
+ } else {
+ const errorMsg = response.data.message || '删除服务失败'
+ addError({
+ message: errorMsg,
+ type: 'delete-error',
+ source: 'deleteService'
+ })
+ return { success: false, error: errorMsg }
+ }
+ } catch (error) {
+ const errorMsg = error.message || '删除服务失败'
+ addError({
+ message: errorMsg,
+ type: 'delete-error',
+ source: 'deleteService'
+ })
+ return { success: false, error: errorMsg }
+ } finally {
+ setLoadingState('removing', false)
+ appStore?.setLoadingState('services', false)
+ }
+ }
+
+ const restartService = async (serviceName) => {
+ try {
+ const response = await api.store.restartService(serviceName)
+ if (response.data.success) {
+ await fetchServices()
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const updateService = async (serviceName, config) => {
+ try {
+ const response = await api.store.updateService(serviceName, config)
+ if (response.data.success) {
+ await fetchServices()
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const batchUpdateServices = async (updates) => {
+ try {
+ const response = await api.store.batchUpdateServices(updates)
+ if (response.data.success) {
+ await fetchServices()
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const batchDeleteServices = async (serviceNames) => {
+ try {
+ const response = await api.store.batchDeleteServices(serviceNames)
+ if (response.data.success) {
+ await fetchServices()
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const batchRestartServices = async (serviceNames) => {
+ try {
+ const response = await api.store.batchRestartServices(serviceNames)
+ if (response.data.success) {
+ await fetchServices()
+ return { success: true }
+ } else {
+ return { success: false, error: response.data.message }
+ }
+ } catch (error) {
+ return { success: false, error: error.message }
+ }
+ }
+
+ const checkServicesHealth = async () => {
+ try {
+ setLoadingState('checking', true)
+
+ const data = await api.store.checkServices()
+ // 更新服务状态
+ if (Array.isArray(data)) {
+ data.forEach(healthInfo => {
+ const service = services.value.find(s => s.name === healthInfo.name)
+ if (service) {
+ service.status = healthInfo.status
+ service.last_heartbeat = healthInfo.last_heartbeat
+
+ // 更新健康状态
+ updateServiceHealth(healthInfo.name, {
+ status: healthInfo.status,
+ lastCheck: Date.now(),
+ details: healthInfo
+ })
+ }
+ })
+ updateStats()
+ }
+ return data
+ } catch (error) {
+ console.error('健康检查失败:', error)
+ addError({
+ message: `健康检查失败: ${error.message}`,
+ type: 'health-check-error',
+ source: 'checkServicesHealth'
+ })
+ throw error
+ } finally {
+ setLoadingState('checking', false)
+ }
+ }
+
+ // 更新服务健康状态
+ const updateServiceHealth = (serviceName, health) => {
+ serviceHealth.value[serviceName] = {
+ ...health,
+ lastCheck: Date.now()
+ }
+ }
+
+ // 更新服务连接状态
+ const updateConnectionStatus = (serviceName, status) => {
+ connectionStatus.value[serviceName] = {
+ ...status,
+ lastUpdate: Date.now()
+ }
+ }
+
+ // 获取系统资源信息
+ const fetchSystemResources = async () => {
+ try {
+ const data = await api.store.getSystemResources()
+ return data
+ } catch (error) {
+ console.error('Failed to fetch system resources:', error)
+ addError({
+ message: `获取系统资源失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchSystemResources'
+ })
+ return null
+ }
+ }
+
+ // 刷新所有数据
+ const refreshAll = async () => {
+ try {
+ setLoadingState('updating', true)
+
+ await Promise.all([
+ fetchServices(true),
+ checkServicesHealth(),
+ fetchSystemResources()
+ ])
+
+ lastUpdateTime.value = new Date()
+
+ appStore?.addNotification({
+ title: '数据刷新完成',
+ message: '所有服务数据已更新',
+ type: 'success'
+ })
+
+ } catch (error) {
+ console.error('Failed to refresh all data:', error)
+ addError({
+ message: `刷新数据失败: ${error.message}`,
+ type: 'refresh-error',
+ source: 'refreshAll'
+ })
+ } finally {
+ setLoadingState('updating', false)
+ }
+ }
+
+ const updateStats = () => {
+ // 安全检查:确保services.value是数组
+ if (!Array.isArray(services.value)) {
+ logger.warn('⚠️ updateStats: services.value不是数组,跳过统计更新')
+ return
+ }
+
+ stats.value.total = services.value.length
+ stats.value.running = services.value.filter(s => s.status === 'healthy' || s.status === 'running').length
+ stats.value.stopped = services.value.filter(s => s.status === 'stopped').length
+ stats.value.error = services.value.filter(s => s.status === 'error' || s.status === 'unhealthy').length
+ stats.value.local = services.value.filter(s => s.command).length
+ stats.value.remote = services.value.filter(s => s.url).length
+ }
+
+ const setCurrentService = (service) => {
+ currentService.value = service
+ }
+
+ const getServiceByName = (name) => {
+ return services.value.find(s => s.name === name)
+ }
+
+ const resetStore = () => {
+ services.value = []
+ currentService.value = null
+ stats.value = {
+ total: 0,
+ running: 0,
+ stopped: 0,
+ error: 0,
+ local: 0,
+ remote: 0
+ }
+ lastUpdateTime.value = null
+
+ // 重置新增状态
+ serviceHealth.value = {}
+ connectionStatus.value = {}
+ serviceMetrics.value = {}
+ errorHandler.clearErrors()
+
+ // 重置加载状态
+ loadingState.resetAll()
+ loading.value = false
+
+ logger.debug('🔄 Services store reset')
+ }
+
+ return {
+ // 原有状态
+ services,
+ currentService,
+ loading,
+ lastUpdateTime,
+ stats,
+
+ // 新增状态
+ serviceHealth,
+ connectionStatus,
+ serviceMetrics,
+ serviceConfig,
+
+ // Composable 实例(用于访问错误和加载状态)
+ errorHandler,
+ loadingState,
+
+ // 原有计算属性
+ servicesByStatus,
+ runningServices,
+ localServices,
+ remoteServices,
+ healthyServices,
+ unhealthyServices,
+
+ // 新增计算属性
+ failedServices,
+ unknownServices,
+ isLoading,
+ hasErrors,
+ recentErrors,
+ activeServices,
+ healthRate,
+
+ // 原有方法
+ fetchServices,
+ addService,
+ deleteService,
+ restartService,
+ updateService,
+ batchUpdateServices,
+ batchDeleteServices,
+ batchRestartServices,
+ checkServicesHealth,
+ updateStats,
+ setCurrentService,
+ getServiceByName,
+ resetStore,
+
+ // 新增方法
+ setLoadingState,
+ addError,
+ clearErrors,
+ updateServiceHealth,
+ updateConnectionStatus,
+ fetchSystemResources,
+ refreshAll
+ }
+})
diff --git a/vue/src/stores/system.js b/vue/src/stores/system.js
new file mode 100644
index 00000000..f2a5e846
--- /dev/null
+++ b/vue/src/stores/system.js
@@ -0,0 +1,783 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { api } from '@/api'
+import { useAppStore } from './app'
+import { logger } from '@/utils/logger'
+
+export const useSystemStore = defineStore('system', () => {
+ const appStore = useAppStore()
+
+ // 状态
+ const services = ref([])
+ const tools = ref([])
+ const agents = ref([])
+ const systemInfo = ref({})
+ const healthStatus = ref({})
+ const loading = ref(false)
+ const lastUpdateTime = ref(null)
+
+ // 统计信息
+ const stats = ref({
+ totalServices: 0,
+ healthyServices: 0,
+ unhealthyServices: 0,
+ totalTools: 0,
+ totalAgents: 0,
+ localServices: 0,
+ remoteServices: 0
+ })
+
+ // 新增状态
+ const systemResources = ref({
+ memory: { total: 0, used: 0, percentage: 0 },
+ disk: { total: 0, used: 0, percentage: 0 },
+ cpu: { usage: 0, cores: 0 },
+ network: { in: 0, out: 0 }
+ })
+
+ const performanceMetrics = ref({
+ apiResponseTimes: [],
+ errorRates: [],
+ throughput: 0,
+ uptime: 0
+ })
+
+ const errors = ref([])
+ const lastError = ref(null)
+
+ // 详细加载状态
+ const loadingStates = ref({
+ services: false,
+ tools: false,
+ agents: false,
+ system: false,
+ health: false,
+ resources: false
+ })
+
+ // 系统配置
+ const systemConfig = ref({
+ autoRefresh: false, // 暂时禁用自动刷新
+ refreshInterval: 60000, // 增加到60秒
+ healthCheckInterval: 120000, // 增加到2分钟
+ maxRetries: 2 // 减少重试次数
+ })
+
+ // 计算属性
+ const systemStatus = computed(() => ({
+ isHealthy: stats.value.unhealthyServices === 0,
+ healthyServices: stats.value.healthyServices,
+ unhealthyServices: stats.value.unhealthyServices,
+ totalServices: stats.value.totalServices,
+ // 从健康状态数据中获取orchestrator状态,如果healthStatus为空则返回false
+ running: healthStatus.value?.orchestrator_status === 'running'
+ }))
+
+ const servicesByStatus = computed(() => {
+ const healthy = services.value.filter(s => s.status === 'healthy')
+ const unhealthy = services.value.filter(s => s.status !== 'healthy')
+ return { healthy, unhealthy }
+ })
+
+ const servicesByType = computed(() => {
+ const local = services.value.filter(s => s.command)
+ const remote = services.value.filter(s => s.url)
+ return { local, remote }
+ })
+
+ const toolsByService = computed(() => {
+ const grouped = {}
+ tools.value.forEach(tool => {
+ const serviceName = tool.service || 'unknown'
+ if (!grouped[serviceName]) {
+ grouped[serviceName] = []
+ }
+ grouped[serviceName].push(tool)
+ })
+ return grouped
+ })
+
+ // 新增计算属性
+ const isLoading = computed(() => {
+ return Object.values(loadingStates.value).some(Boolean) || loading.value
+ })
+
+ const hasErrors = computed(() => {
+ return errors.value.length > 0
+ })
+
+ const recentErrors = computed(() => {
+ return errors.value.slice(-5).reverse()
+ })
+
+ const systemHealthScore = computed(() => {
+ const total = stats.value.totalServices
+ const healthy = stats.value.healthyServices
+ const memoryScore = 100 - systemResources.value.memory.percentage
+ const diskScore = 100 - systemResources.value.disk.percentage
+
+ if (total === 0) return 100
+
+ const serviceScore = (healthy / total) * 100
+ return Math.round((serviceScore + memoryScore + diskScore) / 3)
+ })
+
+ const resourceUsage = computed(() => {
+ return {
+ memory: systemResources.value.memory,
+ disk: systemResources.value.disk,
+ cpu: systemResources.value.cpu,
+ network: systemResources.value.network
+ }
+ })
+
+ const criticalServices = computed(() => {
+ return services.value.filter(s => s.status === 'error' || s.status === 'unhealthy')
+ })
+
+ const availableTools = computed(() => {
+ return tools.value.filter(t => t.available !== false)
+ })
+
+ // 新增方法
+ const setLoadingState = (type, status) => {
+ if (type in loadingStates.value) {
+ loadingStates.value[type] = status
+ }
+ }
+
+ const addError = (error) => {
+ const errorObj = {
+ id: Date.now(),
+ message: error.message || error,
+ timestamp: new Date().toISOString(),
+ type: error.type || 'system-error',
+ source: error.source || 'system-store'
+ }
+
+ errors.value.push(errorObj)
+ lastError.value = errorObj
+
+ // 限制错误数量
+ if (errors.value.length > 50) {
+ errors.value = errors.value.slice(-50)
+ }
+
+ // 同时添加到应用级错误
+ if (appStore) {
+ appStore.addError(errorObj)
+ }
+ }
+
+ const clearErrors = () => {
+ errors.value = []
+ lastError.value = null
+ }
+
+ // 方法
+ const fetchServices = async (force = false) => {
+ if ((loading.value || loadingStates.value.services) && !force) return
+
+ try {
+ logger.debug('🔍 [STORE] 开始获取服务列表...')
+ loading.value = true
+ setLoadingState('services', true)
+ appStore?.setLoadingState('services', true)
+
+ const servicesArr = await api.store.listServices()
+ services.value = Array.isArray(servicesArr) ? servicesArr : []
+
+ logger.debug('🔍 [STORE] 解析后的服务数据:', services.value)
+ logger.debug('🔍 [STORE] 服务数量:', services.value.length)
+ updateStats()
+ lastUpdateTime.value = new Date()
+
+ logger.debug(`📋 Loaded ${services.value.length} services`)
+ return services.value
+ } catch (error) {
+ console.error('❌ [STORE] 获取服务列表失败:', error)
+ addError({
+ message: `获取服务列表失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchServices'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('services', false)
+ appStore?.setLoadingState('services', false)
+ }
+ }
+
+ const fetchTools = async (force = false) => {
+ if ((loading.value || loadingStates.value.tools) && !force) return
+
+ try {
+ loading.value = true
+ setLoadingState('tools', true)
+ appStore?.setLoadingState('tools', true)
+
+ const toolsArr = await api.store.getTools()
+ tools.value = Array.isArray(toolsArr) ? toolsArr : []
+ updateStats()
+ lastUpdateTime.value = new Date()
+
+ logger.debug(`🛠️ Loaded ${tools.value.length} tools`)
+ return tools.value
+ } catch (error) {
+ console.error('Failed to fetch tools:', error)
+ addError({
+ message: `获取工具列表失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchTools'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('tools', false)
+ appStore?.setLoadingState('tools', false)
+ }
+ }
+
+ const fetchAgents = async (force = false) => {
+ if ((loading.value || loadingStates.value.agents) && !force) return
+
+ try {
+ loading.value = true
+ setLoadingState('agents', true)
+ appStore?.setLoadingState('agents', true)
+
+ const agentsArr = await api.store.listAllAgents()
+ agents.value = Array.isArray(agentsArr) ? agentsArr : []
+ updateStats()
+ lastUpdateTime.value = new Date()
+
+ logger.debug(`🤖 Loaded ${agents.value.length} agents`)
+ return agents.value
+ } catch (error) {
+ console.error('Failed to fetch agents:', error)
+ addError({
+ message: `获取代理列表失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchAgents'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('agents', false)
+ appStore?.setLoadingState('agents', false)
+ }
+ }
+
+ const fetchSystemStatus = async () => {
+ try {
+ logger.debug('🔍 [STORE] 开始检查服务状态...')
+ loading.value = true
+ const data = await api.store.checkServices()
+ logger.debug('🔍 [STORE] 服务状态响应:', data)
+ // 修复:checkServices 已返回 data 段,直接赋值
+ healthStatus.value = data || {}
+ logger.debug('🔍 [STORE] 解析后的健康状态:', healthStatus.value)
+ updateStats()
+ lastUpdateTime.value = new Date()
+ return healthStatus.value
+ } catch (error) {
+ console.error('❌ [STORE] 获取服务状态失败:', error)
+ // 设置默认状态,避免无限loading
+ healthStatus.value = {}
+ stats.value = {
+ totalServices: 0,
+ healthyServices: 0,
+ unhealthyServices: 0,
+ totalTools: 0,
+ totalAgents: 0,
+ localServices: 0,
+ remoteServices: 0
+ }
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ // 安全的系统状态检查(静默失败)
+ const safeCheckSystemStatus = async () => {
+ try {
+ await fetchSystemStatus()
+ } catch (error) {
+ // 静默失败,不抛出错误
+ logger.warn('System status check failed silently:', error.message)
+ }
+ }
+
+ const addService = async (serviceConfig) => {
+ try {
+ loading.value = true
+ const response = await api.store.addService(serviceConfig)
+
+ // 检查添加是否成功
+ if (response.data?.success) {
+ // 刷新服务列表
+ await fetchServices()
+ await fetchTools()
+ return response.data
+ } else {
+ // 添加失败,抛出错误
+ throw new Error(response.data?.message || '服务添加失败')
+ }
+ } catch (error) {
+ console.error('Failed to add service:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const deleteService = async (serviceName) => {
+ try {
+ loading.value = true
+ await api.store.deleteService(serviceName)
+
+ // 从本地状态中移除
+ services.value = services.value.filter(s => s.name !== serviceName)
+ tools.value = tools.value.filter(t => t.service !== serviceName)
+
+ updateStats()
+ return true
+ } catch (error) {
+ console.error('Failed to delete service:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const restartService = async (serviceName) => {
+ try {
+ loading.value = true
+ await api.store.restartService(serviceName)
+
+ // 刷新服务状态
+ await fetchSystemStatus()
+
+ return true
+ } catch (error) {
+ console.error('Failed to restart service:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const executeToolAction = async (toolName, args) => {
+ try {
+ loading.value = true
+ const response = await api.store.callTool(toolName, args)
+ // 修复:返回正确的响应数据
+ return response.data
+ } catch (error) {
+ console.error('Failed to execute tool:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const getServiceInfo = async (serviceName) => {
+ try {
+ const response = await api.store.getServiceInfo(serviceName)
+ // 修复:正确提取服务信息
+ return response.data?.data
+ } catch (error) {
+ console.error('Failed to get service info:', error)
+ throw error
+ }
+ }
+
+ const updateService = async (serviceName, config) => {
+ try {
+ loading.value = true
+ const response = await api.store.updateService(serviceName, config)
+
+ if (response.data.success) {
+ // 刷新服务列表
+ await fetchServices()
+ await fetchTools()
+ }
+
+ return response.data.success
+ } catch (error) {
+ console.error('Failed to update service:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const patchService = async (serviceName, updates) => {
+ try {
+ loading.value = true
+ const response = await api.store.patchService(serviceName, updates)
+
+ if (response.data.success) {
+ // 刷新服务列表
+ await fetchServices()
+ await fetchTools()
+ }
+
+ return response.data.success
+ } catch (error) {
+ console.error('Failed to patch service:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const batchUpdateServices = async (serviceNames, updates) => {
+ try {
+ loading.value = true
+ const response = await api.store.batchUpdateServices(serviceNames, updates)
+
+ if (response.data.success) {
+ // 刷新服务列表
+ await fetchServices()
+ await fetchTools()
+ }
+
+ return response.data
+ } catch (error) {
+ console.error('Failed to batch update services:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const batchDeleteServices = async (serviceNames) => {
+ try {
+ loading.value = true
+ const response = await api.store.batchDeleteServices(serviceNames)
+
+ if (response.data.success) {
+ // 从本地状态中移除
+ services.value = services.value.filter(s => !serviceNames.includes(s.name))
+ tools.value = tools.value.filter(t => !serviceNames.includes(t.service))
+ updateStats()
+ }
+
+ return response.data
+ } catch (error) {
+ console.error('Failed to batch delete services:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const batchRestartServices = async (serviceNames) => {
+ try {
+ loading.value = true
+ const response = await api.store.batchRestartServices(serviceNames)
+
+ if (response.data.success) {
+ // 刷新服务状态
+ await fetchServices()
+ await fetchSystemStatus()
+ }
+
+ return response.data
+ } catch (error) {
+ console.error('Failed to batch restart services:', error)
+ throw error
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const updateStats = () => {
+ const totalServices = services.value.length
+ const healthyServices = services.value.filter(s => s.status === 'healthy').length
+ const unhealthyServices = totalServices - healthyServices
+ const totalTools = tools.value.length
+ const localServices = services.value.filter(s => s.command).length
+ const remoteServices = services.value.filter(s => s.url).length
+
+ stats.value = {
+ totalServices,
+ healthyServices,
+ unhealthyServices,
+ totalTools,
+ totalAgents: agents.value.length,
+ localServices,
+ remoteServices
+ }
+ }
+
+ const fetchToolRecords = async (limit = 50, force = false) => {
+ if (loadingStates.value.resources && !force) return
+
+ try {
+ setLoadingState('resources', true)
+
+ const data = await api.store.getToolRecords(limit)
+ logger.debug('API响应:', data) // 调试日志
+
+ // 期望格式: { executions: [...], summary: {...} }
+ if (data && Array.isArray(data.executions)) {
+ logger.debug(`📊 Loaded ${data.executions.length} tool execution records`)
+ return data
+ } else {
+ logger.warn('API响应格式异常:', data)
+ return { executions: [], summary: { total_executions: 0, by_tool: {}, by_service: {} } }
+ }
+ } catch (error) {
+ console.error('获取工具执行记录失败:', error)
+ addError({
+ message: `获取工具执行记录失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchToolRecords'
+ })
+ return { executions: [], summary: { total_executions: 0, by_tool: {}, by_service: {} } }
+ } finally {
+ setLoadingState('resources', false)
+ }
+ }
+
+ // 获取系统资源信息
+ const fetchSystemResources = async () => {
+ try {
+ setLoadingState('resources', true)
+
+ const data = await api.store.getSystemResources()
+
+ systemResources.value = {
+ memory: {
+ total: data.memory_total || 0,
+ used: data.memory_used || 0,
+ percentage: data.memory_percentage || 0
+ },
+ disk: {
+ total: data.disk_total || 0,
+ used: data.disk_used || 0,
+ percentage: data.disk_usage_percentage || 0
+ },
+ cpu: {
+ usage: data.cpu_usage || 0,
+ cores: data.cpu_cores || 0
+ },
+ network: {
+ in: data.network_traffic_in || 0,
+ out: data.network_traffic_out || 0
+ }
+ }
+
+ logger.debug('📊 System resources updated')
+ return systemResources.value
+
+ } catch (error) {
+ console.error('Failed to fetch system resources:', error)
+ addError({
+ message: `获取系统资源失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchSystemResources'
+ })
+ return null
+ } finally {
+ setLoadingState('resources', false)
+ }
+ }
+
+ const refreshAllData = async () => {
+ try {
+ loading.value = true
+ setLoadingState('system', true)
+
+ await Promise.all([
+ fetchServices(true),
+ fetchTools(true),
+ fetchSystemStatus(),
+ fetchSystemResources(),
+ fetchToolRecords(50, true)
+ ])
+
+ lastUpdateTime.value = new Date()
+
+ appStore?.addNotification({
+ title: '数据刷新完成',
+ message: '所有系统数据已更新',
+ type: 'success'
+ })
+
+ logger.debug('🔄 All system data refreshed')
+ } catch (error) {
+ console.error('Failed to refresh data:', error)
+ addError({
+ message: `刷新系统数据失败: ${error.message}`,
+ type: 'refresh-error',
+ source: 'refreshAllData'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('system', false)
+ }
+ }
+
+ const searchServices = (query) => {
+ if (!query) return services.value
+
+ const lowerQuery = query.toLowerCase()
+ return services.value.filter(service =>
+ service.name.toLowerCase().includes(lowerQuery) ||
+ (service.url && service.url.toLowerCase().includes(lowerQuery)) ||
+ (service.command && service.command.toLowerCase().includes(lowerQuery))
+ )
+ }
+
+ const searchTools = (query) => {
+ if (!query) return tools.value
+
+ const lowerQuery = query.toLowerCase()
+ return tools.value.filter(tool =>
+ tool.name.toLowerCase().includes(lowerQuery) ||
+ (tool.description && tool.description.toLowerCase().includes(lowerQuery)) ||
+ (tool.service && tool.service.toLowerCase().includes(lowerQuery))
+ )
+ }
+
+ const getServiceByName = (name) => {
+ return services.value.find(service => service.name === name)
+ }
+
+ const getToolsByService = (serviceName) => {
+ return tools.value.filter(tool => tool.service === serviceName)
+ }
+
+ const clearData = () => {
+ services.value = []
+ tools.value = []
+ agents.value = []
+ systemInfo.value = {}
+ healthStatus.value = {}
+ stats.value = {
+ totalServices: 0,
+ healthyServices: 0,
+ unhealthyServices: 0,
+ totalTools: 0,
+ totalAgents: 0,
+ localServices: 0,
+ remoteServices: 0
+ }
+ lastUpdateTime.value = null
+ }
+
+ return {
+ // 原有状态
+ services,
+ tools,
+ agents,
+ systemInfo,
+ healthStatus,
+ loading,
+ lastUpdateTime,
+ stats,
+
+ // 新增状态
+ systemResources,
+ performanceMetrics,
+ errors,
+ lastError,
+ loadingStates,
+ systemConfig,
+
+ // 原有计算属性
+ systemStatus,
+ servicesByStatus,
+ servicesByType,
+ toolsByService,
+
+ // 新增计算属性
+ isLoading,
+ hasErrors,
+ recentErrors,
+ systemHealthScore,
+ resourceUsage,
+ criticalServices,
+ availableTools,
+
+ // 方法
+ fetchServices,
+ fetchTools,
+ fetchAgents,
+ fetchSystemStatus,
+ safeCheckSystemStatus,
+ addService,
+ deleteService,
+ updateService,
+ patchService,
+ batchUpdateServices,
+ batchDeleteServices,
+ batchRestartServices,
+ restartService,
+ executeToolAction,
+ getServiceInfo,
+ updateStats,
+ fetchToolRecords,
+ refreshAllData,
+ searchServices,
+ searchTools,
+ getServiceByName,
+ getToolsByService,
+ clearData,
+
+ // 重置Store状态
+ resetStore: () => {
+ services.value = []
+ tools.value = []
+ agents.value = []
+ systemInfo.value = {}
+ healthStatus.value = {}
+ stats.value = {
+ totalServices: 0,
+ healthyServices: 0,
+ unhealthyServices: 0,
+ totalTools: 0,
+ totalAgents: 0,
+ localServices: 0,
+ remoteServices: 0
+ }
+
+ // 重置新增状态
+ systemResources.value = {
+ memory: { total: 0, used: 0, percentage: 0 },
+ disk: { total: 0, used: 0, percentage: 0 },
+ cpu: { usage: 0, cores: 0 },
+ network: { in: 0, out: 0 }
+ }
+ performanceMetrics.value = {
+ apiResponseTimes: [],
+ errorRates: [],
+ throughput: 0,
+ uptime: 0
+ }
+ errors.value = []
+ lastError.value = null
+
+ // 重置加载状态
+ Object.keys(loadingStates.value).forEach(key => {
+ loadingStates.value[key] = false
+ })
+ loading.value = false
+ lastUpdateTime.value = null
+
+ logger.debug('🔄 System store reset')
+ },
+
+ // 新增方法
+ setLoadingState,
+ addError,
+ clearErrors,
+ fetchSystemResources
+ }
+})
diff --git a/vue/src/stores/tabs.js b/vue/src/stores/tabs.js
new file mode 100644
index 00000000..6b510934
--- /dev/null
+++ b/vue/src/stores/tabs.js
@@ -0,0 +1,27 @@
+import { defineStore } from 'pinia'
+
+export const useTabsStore = defineStore('tabs', {
+ state: () => ({
+ tabs: [
+ { path: '/dashboard', title: '仪表板' }
+ ]
+ }),
+ actions: {
+ add(tab) {
+ if (!tab || !tab.path) return
+ if (!this.tabs.find(t => t.path === tab.path)) {
+ this.tabs.push({
+ path: tab.path,
+ title: tab.title || tab.name || '未命名'
+ })
+ }
+ },
+ remove(path) {
+ this.tabs = this.tabs.filter(t => t.path !== path)
+ },
+ lastOrHome() {
+ return this.tabs[this.tabs.length - 1]?.path || '/dashboard'
+ }
+ }
+})
+
diff --git a/vue/src/stores/toolExecution.js b/vue/src/stores/toolExecution.js
new file mode 100644
index 00000000..de564810
--- /dev/null
+++ b/vue/src/stores/toolExecution.js
@@ -0,0 +1,532 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { api } from '@/api'
+import { useAppStore } from './app'
+
+/**
+ * 工具执行状态管理Store
+ * 专门管理工具的执行状态、历史记录、统计信息等
+ */
+export const useToolExecutionStore = defineStore('toolExecution', () => {
+ const appStore = useAppStore()
+
+ // ==================== 状态定义 ====================
+
+ // 执行历史和记录
+ const executionHistory = ref([])
+ const toolRecords = ref({
+ executions: [],
+ summary: {
+ total_executions: 0,
+ by_tool: {},
+ by_service: {}
+ }
+ })
+
+ // 当前执行状态
+ const currentExecutions = ref(new Map()) // executionId -> execution info
+ const executionQueue = ref([]) // 待执行的工具队列
+
+ // 执行统计
+ const statistics = ref({
+ totalExecutions: 0,
+ successfulExecutions: 0,
+ failedExecutions: 0,
+ averageResponseTime: 0,
+ successRate: 0,
+ todayExecutions: 0
+ })
+
+ // 加载状态
+ const loading = ref({
+ executing: false,
+ records: false,
+ history: false
+ })
+
+ // 错误状态
+ const errors = ref([])
+ const lastError = ref(null)
+
+ // 配置
+ const config = ref({
+ maxHistorySize: 1000,
+ maxRecordsSize: 500,
+ autoSaveHistory: true,
+ defaultTimeout: 30000,
+ retryAttempts: 3,
+ batchSize: 10
+ })
+
+ // ==================== 计算属性 ====================
+
+ // 是否正在执行
+ const isExecuting = computed(() => {
+ return currentExecutions.value.size > 0 || loading.value.executing
+ })
+
+ // 是否有任何加载状态
+ const isLoading = computed(() => {
+ return Object.values(loading.value).some(Boolean)
+ })
+
+ // 是否有错误
+ const hasErrors = computed(() => {
+ return errors.value.length > 0
+ })
+
+ // 最近的执行记录
+ const recentExecutions = computed(() => {
+ return executionHistory.value
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .slice(0, 10)
+ })
+
+ // 热门工具(按执行次数排序)
+ const popularTools = computed(() => {
+ const toolCounts = {}
+
+ // 🔧 修复:确保executions数组存在
+ if (!toolRecords.value.executions || !Array.isArray(toolRecords.value.executions)) {
+ console.warn('⚠️ toolRecords.executions 不是有效数组:', toolRecords.value.executions)
+ return []
+ }
+
+ toolRecords.value.executions.forEach(execution => {
+ const toolName = execution.tool_name
+ if (!toolName) return // 跳过无效记录
+
+ if (!toolCounts[toolName]) {
+ toolCounts[toolName] = {
+ // 🔧 修复:使用模板期望的字段名
+ tool_name: toolName, // 模板期望 tool_name
+ service_name: execution.service_name, // 模板期望 service_name
+ last_executed: execution.execution_time, // 模板期望 last_executed
+ execution_count: 0, // 模板期望 execution_count
+ average_response_time: 0, // 模板期望 average_response_time
+ success_rate: 0, // 模板期望 success_rate
+ total_response_time: 0, // 内部计算用
+ successful_count: 0, // 内部计算用
+ failed_count: 0 // 内部计算用
+ }
+ }
+
+ const tool = toolCounts[toolName]
+ tool.execution_count++
+ tool.total_response_time += execution.response_time || 0
+ tool.average_response_time = tool.total_response_time / tool.execution_count
+
+ // 统计成功/失败次数
+ if (execution.error) {
+ tool.failed_count++
+ } else {
+ tool.successful_count++
+ }
+
+ // 计算成功率
+ tool.success_rate = tool.execution_count > 0 ?
+ (tool.successful_count / tool.execution_count * 100) : 0
+
+ // 更新最后执行时间
+ if (execution.execution_time &&
+ (!tool.last_executed || new Date(execution.execution_time) > new Date(tool.last_executed))) {
+ tool.last_executed = execution.execution_time
+ }
+ })
+
+ // 🔧 修复:返回正确格式的数据
+ const result = Object.values(toolCounts)
+ .sort((a, b) => b.execution_count - a.execution_count)
+ .slice(0, 10)
+ .map(tool => ({
+ tool_name: tool.tool_name,
+ service_name: tool.service_name,
+ last_executed: tool.last_executed,
+ execution_count: tool.execution_count,
+ average_response_time: Math.round(tool.average_response_time * 100) / 100, // 保留2位小数
+ success_rate: Math.round(tool.success_rate * 10) / 10 // 保留1位小数
+ }))
+
+ console.log('🔍 [DEBUG] popularTools 计算结果:', result)
+ return result
+ })
+
+ // 执行成功率
+ const successRate = computed(() => {
+ const total = statistics.value.totalExecutions
+ const successful = statistics.value.successfulExecutions
+ return total > 0 ? (successful / total * 100).toFixed(1) : 0
+ })
+
+ // 今天的执行统计 - 🔧 修复:基于真实API数据
+ const todayStats = computed(() => {
+ const today = new Date().toDateString()
+
+ // 🔧 优先使用真实的API数据
+ if (toolRecords.value.executions && Array.isArray(toolRecords.value.executions)) {
+ const todayExecutions = toolRecords.value.executions.filter(exec => {
+ if (!exec.execution_time) return false
+ return new Date(exec.execution_time).toDateString() === today
+ })
+
+ const successful = todayExecutions.filter(exec => !exec.error).length
+ const failed = todayExecutions.filter(exec => exec.error).length
+
+ console.log('🔍 [DEBUG] 今日统计 (基于API数据):', {
+ total: todayExecutions.length,
+ successful,
+ failed,
+ todayDate: today
+ })
+
+ return {
+ total: todayExecutions.length,
+ successful,
+ failed,
+ successRate: todayExecutions.length > 0 ? (successful / todayExecutions.length * 100).toFixed(1) : 0
+ }
+ }
+
+ // 🔧 回退到本地历史数据
+ const todayExecutions = executionHistory.value.filter(exec =>
+ new Date(exec.timestamp).toDateString() === today
+ )
+
+ const successful = todayExecutions.filter(exec => exec.success).length
+ const failed = todayExecutions.filter(exec => !exec.success).length
+
+ console.log('🔍 [DEBUG] 今日统计 (基于本地数据):', {
+ total: todayExecutions.length,
+ successful,
+ failed,
+ todayDate: today
+ })
+
+ return {
+ total: todayExecutions.length,
+ successful,
+ failed,
+ successRate: todayExecutions.length > 0 ? (successful / todayExecutions.length * 100).toFixed(1) : 0
+ }
+ })
+
+ // 按服务分组的执行统计
+ const executionsByService = computed(() => {
+ const serviceStats = {}
+ toolRecords.value.executions.forEach(execution => {
+ const serviceName = execution.service_name || 'unknown'
+ if (!serviceStats[serviceName]) {
+ serviceStats[serviceName] = {
+ name: serviceName,
+ count: 0,
+ tools: new Set(),
+ avgResponseTime: 0,
+ totalResponseTime: 0
+ }
+ }
+ serviceStats[serviceName].count++
+ serviceStats[serviceName].tools.add(execution.tool_name)
+ serviceStats[serviceName].totalResponseTime += execution.response_time || 0
+ serviceStats[serviceName].avgResponseTime = serviceStats[serviceName].totalResponseTime / serviceStats[serviceName].count
+ })
+
+ // 转换Set为数组
+ Object.values(serviceStats).forEach(stat => {
+ stat.tools = Array.from(stat.tools)
+ })
+
+ return serviceStats
+ })
+
+ // 最近的错误
+ const recentErrors = computed(() => {
+ return errors.value.slice(-5).reverse()
+ })
+
+ // 执行队列状态
+ const queueStatus = computed(() => {
+ return {
+ pending: executionQueue.value.length,
+ running: currentExecutions.value.size,
+ isEmpty: executionQueue.value.length === 0 && currentExecutions.value.size === 0
+ }
+ })
+
+ // ==================== 操作方法 ====================
+
+ // 设置加载状态
+ const setLoading = (type, status) => {
+ if (type in loading.value) {
+ loading.value[type] = status
+ }
+ }
+
+ // 添加错误
+ const addError = (error) => {
+ const errorObj = {
+ id: Date.now(),
+ message: error.message || error,
+ timestamp: new Date().toISOString(),
+ type: error.type || 'execution-error',
+ source: error.source || 'tool-execution-store',
+ toolName: error.toolName
+ }
+
+ errors.value.push(errorObj)
+ lastError.value = errorObj
+
+ // 限制错误数量
+ if (errors.value.length > 100) {
+ errors.value = errors.value.slice(-100)
+ }
+
+ // 同时添加到应用级错误
+ if (appStore) {
+ appStore.addError(errorObj)
+ }
+ }
+
+ // 清除错误
+ const clearErrors = () => {
+ errors.value = []
+ lastError.value = null
+ }
+
+ // 添加执行记录到历史
+ const addExecutionToHistory = (execution) => {
+ executionHistory.value.unshift(execution)
+
+ // 限制历史记录数量
+ if (executionHistory.value.length > config.value.maxHistorySize) {
+ executionHistory.value = executionHistory.value.slice(0, config.value.maxHistorySize)
+ }
+
+ // 更新统计
+ updateStatistics()
+
+ // 自动保存到localStorage
+ if (config.value.autoSaveHistory) {
+ saveHistoryToStorage()
+ }
+ }
+
+ // 更新统计信息
+ const updateStatistics = () => {
+ const total = executionHistory.value.length
+ const successful = executionHistory.value.filter(exec => exec.success).length
+ const failed = total - successful
+
+ let totalResponseTime = 0
+ executionHistory.value.forEach(exec => {
+ if (exec.duration) {
+ totalResponseTime += exec.duration
+ }
+ })
+
+ statistics.value = {
+ totalExecutions: total,
+ successfulExecutions: successful,
+ failedExecutions: failed,
+ averageResponseTime: total > 0 ? Math.round(totalResponseTime / total) : 0,
+ successRate: total > 0 ? (successful / total * 100).toFixed(1) : 0,
+ todayExecutions: todayStats.value.total
+ }
+ }
+
+ // 保存历史到localStorage
+ const saveHistoryToStorage = () => {
+ try {
+ const historyToSave = executionHistory.value.slice(0, 100) // 只保存最近100条
+ localStorage.setItem('mcpstore-execution-history', JSON.stringify(historyToSave))
+ } catch (error) {
+ console.warn('Failed to save execution history to localStorage:', error)
+ }
+ }
+
+ // 从localStorage加载历史
+ const loadHistoryFromStorage = () => {
+ try {
+ const saved = localStorage.getItem('mcpstore-execution-history')
+ if (saved) {
+ const parsed = JSON.parse(saved)
+ if (Array.isArray(parsed)) {
+ executionHistory.value = parsed
+ updateStatistics()
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to load execution history from localStorage:', error)
+ }
+ }
+
+ // 获取工具执行记录
+ const fetchToolRecords = async (limit = 50, force = false) => {
+ if (loading.value.records && !force) return toolRecords.value
+
+ try {
+ setLoading('records', true)
+
+ console.log('🔍 [DEBUG] 开始获取工具执行记录...')
+ const response = await api.store.getToolRecords(limit)
+ console.log('🔍 [DEBUG] API响应:', response)
+
+ // 🔧 修复:正确处理API响应格式
+ let data = null
+
+ // 处理不同的响应格式
+ if (response.data && response.data.success && response.data.data) {
+ // 新格式:{ success: true, data: { executions: [...], summary: {...} } }
+ data = response.data.data
+ console.log('✅ [DEBUG] 使用新格式 response.data.data')
+ } else if (response.data && response.data.executions) {
+ // 直接格式:{ executions: [...], summary: {...} }
+ data = response.data
+ console.log('✅ [DEBUG] 使用直接格式 response.data')
+ } else {
+ console.warn('⚠️ [DEBUG] 无法识别的API响应格式')
+ data = { executions: [], summary: { total_executions: 0, by_tool: {}, by_service: {} } }
+ }
+
+ console.log('🔍 [DEBUG] 提取的数据:', data)
+ console.log('🔍 [DEBUG] executions数量:', data.executions?.length || 0)
+
+ // 确保数据结构正确
+ if (data && typeof data === 'object') {
+ // 确保executions字段存在且为数组
+ if (!data.executions || !Array.isArray(data.executions)) {
+ console.warn('⚠️ [DEBUG] executions字段无效,使用空数组')
+ data.executions = []
+ }
+
+ // 确保summary字段存在
+ if (!data.summary || typeof data.summary !== 'object') {
+ console.warn('⚠️ [DEBUG] summary字段无效,使用默认结构')
+ data.summary = { total_executions: 0, by_tool: {}, by_service: {} }
+ }
+
+ toolRecords.value = data
+ } else {
+ // 如果数据格式不正确,使用默认结构
+ console.warn('⚠️ [DEBUG] 数据格式不正确,使用默认结构')
+ toolRecords.value = {
+ executions: [],
+ summary: {
+ total_executions: 0,
+ by_tool: {},
+ by_service: {}
+ }
+ }
+ }
+
+ // 限制记录数量
+ if (toolRecords.value.executions && toolRecords.value.executions.length > config.value.maxRecordsSize) {
+ toolRecords.value.executions = toolRecords.value.executions.slice(0, config.value.maxRecordsSize)
+ }
+
+ console.log(`📊 Loaded ${toolRecords.value.executions?.length || 0} tool execution records`)
+ console.log('🔍 [DEBUG] 最终toolRecords:', toolRecords.value)
+
+ return toolRecords.value
+ } catch (error) {
+ console.error('获取工具记录失败:', error)
+ addError({
+ message: `获取工具记录失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchToolRecords'
+ })
+ throw error
+ } finally {
+ setLoading('records', false)
+ }
+ }
+
+ // 清除执行历史
+ const clearExecutionHistory = () => {
+ executionHistory.value = []
+ updateStatistics()
+ saveHistoryToStorage()
+
+ appStore?.addNotification({
+ title: '执行历史已清除',
+ message: '所有工具执行历史记录已清除',
+ type: 'info'
+ })
+ }
+
+ // 清除工具记录
+ const clearToolRecords = () => {
+ toolRecords.value = {
+ executions: [],
+ summary: {
+ total_executions: 0,
+ by_tool: {},
+ by_service: {}
+ }
+ }
+ }
+
+ // 重置Store状态
+ const resetStore = () => {
+ executionHistory.value = []
+ clearToolRecords()
+ currentExecutions.value.clear()
+ executionQueue.value = []
+ statistics.value = {
+ totalExecutions: 0,
+ successfulExecutions: 0,
+ failedExecutions: 0,
+ averageResponseTime: 0,
+ successRate: 0,
+ todayExecutions: 0
+ }
+ errors.value = []
+ lastError.value = null
+
+ Object.keys(loading.value).forEach(key => {
+ loading.value[key] = false
+ })
+
+ // 清除localStorage
+ localStorage.removeItem('mcpstore-execution-history')
+
+ console.log('🔄 Tool execution store reset')
+ }
+
+ return {
+ // 状态
+ executionHistory,
+ toolRecords,
+ currentExecutions,
+ executionQueue,
+ statistics,
+ loading,
+ errors,
+ lastError,
+ config,
+
+ // 计算属性
+ isExecuting,
+ isLoading,
+ hasErrors,
+ recentExecutions,
+ popularTools,
+ successRate,
+ todayStats,
+ executionsByService,
+ recentErrors,
+ queueStatus,
+
+ // 方法
+ setLoading,
+ addError,
+ clearErrors,
+ addExecutionToHistory,
+ updateStatistics,
+ saveHistoryToStorage,
+ loadHistoryFromStorage,
+ fetchToolRecords,
+ clearExecutionHistory,
+ clearToolRecords,
+ resetStore
+ }
+})
diff --git a/vue/src/stores/tools.js b/vue/src/stores/tools.js
new file mode 100644
index 00000000..ab0308ac
--- /dev/null
+++ b/vue/src/stores/tools.js
@@ -0,0 +1,600 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { api } from '@/api'
+import { useAppStore } from './app'
+
+export const useToolsStore = defineStore('tools', () => {
+ const appStore = useAppStore()
+
+ // 状态
+ const tools = ref([])
+ const currentTool = ref(null)
+ const executionHistory = ref([])
+ const loading = ref(false)
+ const executing = ref(false)
+ const lastUpdateTime = ref(null)
+
+ // 工具统计
+ const stats = ref({
+ total: 0,
+ byService: {},
+ recentExecutions: 0,
+ successfulExecutions: 0,
+ failedExecutions: 0
+ })
+
+ // 新增状态
+ const toolRecords = ref({
+ executions: [],
+ summary: {
+ total_executions: 0,
+ by_tool: {},
+ by_service: {}
+ }
+ })
+
+ const currentExecutions = ref(new Map()) // 当前正在执行的工具
+ const errors = ref([])
+ const lastError = ref(null)
+
+ // 详细加载状态
+ const loadingStates = ref({
+ tools: false,
+ executing: false,
+ records: false,
+ details: false
+ })
+
+ // 工具配置
+ const toolConfig = ref({
+ autoSave: true,
+ maxHistorySize: 1000,
+ defaultTimeout: 30000,
+ retryAttempts: 3
+ })
+
+ // 计算属性
+ const toolsByService = computed(() => {
+ return tools.value.reduce((acc, tool) => {
+ const service = tool.service_name || 'unknown'
+ if (!acc[service]) acc[service] = []
+ acc[service].push(tool)
+ return acc
+ }, {})
+ })
+
+ const serviceNames = computed(() => {
+ const names = new Set(tools.value.map(tool => tool.service_name))
+ return Array.from(names).sort()
+ })
+
+ const recentExecutions = computed(() => {
+ return executionHistory.value
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
+ .slice(0, 10)
+ })
+
+ const popularTools = computed(() => {
+ const toolCounts = {}
+ executionHistory.value.forEach(execution => {
+ toolCounts[execution.toolName] = (toolCounts[execution.toolName] || 0) + 1
+ })
+
+ return Object.entries(toolCounts)
+ .sort(([,a], [,b]) => b - a)
+ .slice(0, 10)
+ .map(([toolName, count]) => ({
+ name: toolName,
+ count,
+ tool: tools.value.find(t => t.name === toolName)
+ }))
+ })
+
+ // 新增计算属性
+ const isLoading = computed(() => {
+ return Object.values(loadingStates.value).some(Boolean) || loading.value
+ })
+
+ const hasErrors = computed(() => {
+ return errors.value.length > 0
+ })
+
+ const recentErrors = computed(() => {
+ return errors.value.slice(-5).reverse()
+ })
+
+ const isExecuting = computed(() => {
+ return currentExecutions.value.size > 0 || executing.value
+ })
+
+ const executionStats = computed(() => {
+ const total = toolRecords.value.summary.total_executions
+ const successful = executionHistory.value.filter(e => e.success).length
+ const failed = executionHistory.value.filter(e => !e.success).length
+
+ return {
+ total,
+ successful,
+ failed,
+ successRate: total > 0 ? (successful / total * 100).toFixed(1) : 0
+ }
+ })
+
+ const toolsByCategory = computed(() => {
+ const categories = {}
+ tools.value.forEach(tool => {
+ const category = tool.category || 'uncategorized'
+ if (!categories[category]) categories[category] = []
+ categories[category].push(tool)
+ })
+ return categories
+ })
+
+ const availableTools = computed(() => {
+ return tools.value.filter(tool => tool.available !== false)
+ })
+
+ const favoriteTools = computed(() => {
+ return tools.value.filter(tool => tool.favorite === true)
+ })
+
+ // 新增方法
+ const setLoadingState = (type, status) => {
+ if (type in loadingStates.value) {
+ loadingStates.value[type] = status
+ }
+ }
+
+ const addError = (error) => {
+ const errorObj = {
+ id: Date.now(),
+ message: error.message || error,
+ timestamp: new Date().toISOString(),
+ type: error.type || 'tool-error',
+ source: error.source || 'tools-store'
+ }
+
+ errors.value.push(errorObj)
+ lastError.value = errorObj
+
+ // 限制错误数量
+ if (errors.value.length > 50) {
+ errors.value = errors.value.slice(-50)
+ }
+
+ // 同时添加到应用级错误
+ if (appStore) {
+ appStore.addError(errorObj)
+ }
+ }
+
+ const clearErrors = () => {
+ errors.value = []
+ lastError.value = null
+ }
+
+ // 方法
+ const fetchTools = async (force = false) => {
+ if ((loading.value || loadingStates.value.tools) && !force) return
+
+ loading.value = true
+ setLoadingState('tools', true)
+
+ try {
+ appStore?.setLoadingState('tools', true)
+
+ const response = await api.store.listTools()
+
+ // 🔍 调试:检查API返回的数据格式
+ console.log('🔍 [DEBUG] Tools API返回的原始数据:', response)
+ console.log('🔍 [DEBUG] response.data类型:', typeof response.data)
+ console.log('🔍 [DEBUG] response.data是否为数组:', Array.isArray(response.data))
+
+ // 🔧 修复:正确处理API响应格式
+ let toolsData = []
+
+ if (response.data && response.data.success && Array.isArray(response.data.data)) {
+ // 新格式:{ success: true, data: [...] }
+ toolsData = response.data.data
+ console.log('✅ [DEBUG] 使用新格式 response.data.data')
+ } else if (Array.isArray(response.data)) {
+ // 直接数组格式
+ toolsData = response.data
+ console.log('✅ [DEBUG] 使用 response.data (直接数组)')
+ } else if (Array.isArray(response)) {
+ // 响应本身是数组
+ toolsData = response
+ console.log('✅ [DEBUG] 使用 response (直接数组)')
+ } else if (response.data && Array.isArray(response.data.tools)) {
+ // 嵌套格式:{ data: { tools: [...] } }
+ toolsData = response.data.tools
+ console.log('✅ [DEBUG] 使用 response.data.tools')
+ } else {
+ console.warn('⚠️ [DEBUG] 无法识别的Tools API响应格式')
+ console.warn('响应结构:', {
+ hasData: !!response.data,
+ hasSuccess: !!(response.data && response.data.success),
+ hasDataData: !!(response.data && response.data.data),
+ dataType: typeof response.data,
+ dataDataType: response.data && typeof response.data.data
+ })
+ toolsData = []
+ }
+
+ // 确保每个工具都有必要的字段
+ tools.value = toolsData.map(tool => ({
+ ...tool,
+ available: tool.available !== false, // 默认为可用
+ favorite: tool.favorite || false,
+ category: tool.category || 'default'
+ }))
+
+ console.log('🔍 [DEBUG] 提取的工具数据:', toolsData)
+ console.log('🔍 [DEBUG] 处理后的tools.value:', tools.value)
+ console.log('🔍 [DEBUG] 工具数量:', tools.value.length)
+ console.log('🔍 [DEBUG] 可用工具数量:', tools.value.filter(t => t.available !== false).length)
+
+ updateStats()
+ lastUpdateTime.value = new Date()
+
+ console.log(`🛠️ Loaded ${tools.value.length} tools`)
+ return tools.value
+ } catch (error) {
+ console.error('获取工具列表失败:', error)
+ addError({
+ message: `获取工具列表失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'fetchTools'
+ })
+ throw error
+ } finally {
+ loading.value = false
+ setLoadingState('tools', false)
+ appStore?.setLoadingState('tools', false)
+ }
+ }
+
+ const executeTool = async (toolName, params) => {
+ const executionId = `${toolName}_${Date.now()}`
+
+ try {
+ executing.value = true
+ setLoadingState('executing', true)
+
+ // 记录开始执行
+ currentExecutions.value.set(executionId, {
+ toolName,
+ params,
+ startTime: Date.now(),
+ status: 'running'
+ })
+
+ const startTime = Date.now()
+ const response = await api.store.callTool(toolName, params)
+ const endTime = Date.now()
+ const duration = endTime - startTime
+
+ // 添加到执行历史
+ const execution = {
+ id: Date.now(),
+ toolName,
+ params,
+ result: response.data,
+ success: response.data.success !== false,
+ timestamp: new Date().toISOString(),
+ duration,
+ message: response.data.message || ''
+ }
+
+ executionHistory.value.unshift(execution)
+
+ // 限制历史记录数量
+ if (executionHistory.value.length > toolConfig.value.maxHistorySize) {
+ executionHistory.value = executionHistory.value.slice(0, toolConfig.value.maxHistorySize)
+ }
+
+ updateStats()
+
+ // 添加成功通知
+ if (execution.success) {
+ appStore?.addNotification({
+ title: '工具执行成功',
+ message: `工具 "${toolName}" 执行完成`,
+ type: 'success'
+ })
+ }
+
+ return response
+ } catch (error) {
+ const endTime = Date.now()
+ const duration = endTime - startTime
+
+ // 添加失败的执行记录
+ const execution = {
+ id: Date.now(),
+ toolName,
+ params,
+ result: null,
+ success: false,
+ timestamp: new Date().toISOString(),
+ duration,
+ message: error.message || '执行失败'
+ }
+
+ executionHistory.value.unshift(execution)
+ updateStats()
+
+ // 添加错误
+ addError({
+ message: `工具执行失败: ${error.message}`,
+ type: 'execution-error',
+ source: 'executeTool',
+ toolName
+ })
+
+ throw error
+ } finally {
+ executing.value = false
+ setLoadingState('executing', false)
+ currentExecutions.value.delete(executionId)
+ }
+ }
+
+ const getToolDetails = async (toolName) => {
+ try {
+ const response = await api.store.getToolInfo(toolName)
+ return response.data
+ } catch (error) {
+ console.error('获取工具详情失败:', error)
+ throw error
+ }
+ }
+
+ const getToolRecords = async (limit = 50, force = false) => {
+ if (loadingStates.value.records && !force) return toolRecords.value
+
+ try {
+ setLoadingState('records', true)
+
+ const response = await api.store.getToolRecords(limit)
+ // api.store.getToolRecords 使用 extractResponseData 返回 data 段
+ // 兼容两种形式:直接是 data 对象,或 { data: {...} }
+ const raw = response && response.data ? response.data : response
+ const data = raw && (raw.executions || raw.summary) ? raw : { executions: [], summary: { total_executions: 0, by_tool: {}, by_service: {} } }
+
+ // 更新本地状态
+ toolRecords.value = data
+
+ console.log(`📊 Loaded ${data.executions.length} tool execution records`)
+ return data
+ } catch (error) {
+ console.error('获取工具记录失败:', error)
+ addError({
+ message: `获取工具记录失败: ${error.message}`,
+ type: 'fetch-error',
+ source: 'getToolRecords'
+ })
+ throw error
+ } finally {
+ setLoadingState('records', false)
+ }
+ }
+
+ // 获取工具执行统计
+ const fetchToolExecutionStats = async () => {
+ try {
+ const records = await getToolRecords(100, true)
+
+ // 更新执行统计
+ const totalExecutions = records.summary.total_executions
+ const recentExecutions = records.executions.slice(0, 10)
+
+ // 计算成功率
+ const successfulExecutions = records.executions.filter(e => !e.error).length
+ const failedExecutions = records.executions.filter(e => e.error).length
+
+ stats.value.recentExecutions = recentExecutions.length
+ stats.value.successfulExecutions = successfulExecutions
+ stats.value.failedExecutions = failedExecutions
+
+ return {
+ total: totalExecutions,
+ successful: successfulExecutions,
+ failed: failedExecutions,
+ recent: recentExecutions
+ }
+ } catch (error) {
+ addError({
+ message: `获取执行统计失败: ${error.message}`,
+ type: 'stats-error',
+ source: 'fetchToolExecutionStats'
+ })
+ return null
+ }
+ }
+
+ // 标记工具为收藏
+ const toggleToolFavorite = (toolName) => {
+ const tool = tools.value.find(t => t.name === toolName)
+ if (tool) {
+ tool.favorite = !tool.favorite
+
+ // 保存到localStorage
+ const favorites = JSON.parse(localStorage.getItem('mcpstore-favorite-tools') || '[]')
+ if (tool.favorite) {
+ if (!favorites.includes(toolName)) {
+ favorites.push(toolName)
+ }
+ } else {
+ const index = favorites.indexOf(toolName)
+ if (index > -1) {
+ favorites.splice(index, 1)
+ }
+ }
+ localStorage.setItem('mcpstore-favorite-tools', JSON.stringify(favorites))
+ }
+ }
+
+ // 加载收藏工具
+ const loadFavoriteTools = () => {
+ try {
+ const favorites = JSON.parse(localStorage.getItem('mcpstore-favorite-tools') || '[]')
+ tools.value.forEach(tool => {
+ tool.favorite = favorites.includes(tool.name)
+ })
+ } catch (error) {
+ console.warn('Failed to load favorite tools:', error)
+ }
+ }
+
+ const updateStats = () => {
+ // 安全检查:确保tools.value是数组
+ if (!Array.isArray(tools.value)) {
+ console.warn('⚠️ updateStats: tools.value不是数组,跳过统计更新')
+ return
+ }
+
+ stats.value.total = tools.value.length
+
+ // 按服务统计
+ stats.value.byService = {}
+ tools.value.forEach(tool => {
+ const service = tool.service_name || 'unknown'
+ stats.value.byService[service] = (stats.value.byService[service] || 0) + 1
+ })
+
+ // 执行统计
+ stats.value.recentExecutions = executionHistory.value.length
+ stats.value.successfulExecutions = executionHistory.value.filter(e => e.success).length
+ stats.value.failedExecutions = executionHistory.value.filter(e => !e.success).length
+ }
+
+ const setCurrentTool = (tool) => {
+ currentTool.value = tool
+ }
+
+ const getToolByName = (name) => {
+ return tools.value.find(t => t.name === name)
+ }
+
+ const getToolsByService = (serviceName) => {
+ return tools.value.filter(t => t.service_name === serviceName)
+ }
+
+ const searchTools = (query) => {
+ if (!query) return tools.value
+
+ const lowerQuery = query.toLowerCase()
+ return tools.value.filter(tool =>
+ tool.name.toLowerCase().includes(lowerQuery) ||
+ (tool.description && tool.description.toLowerCase().includes(lowerQuery)) ||
+ (tool.service_name && tool.service_name.toLowerCase().includes(lowerQuery))
+ )
+ }
+
+ const clearExecutionHistory = () => {
+ executionHistory.value = []
+ updateStats()
+ }
+
+ const removeExecutionFromHistory = (executionId) => {
+ const index = executionHistory.value.findIndex(e => e.id === executionId)
+ if (index > -1) {
+ executionHistory.value.splice(index, 1)
+ updateStats()
+ }
+ }
+
+ const resetStore = () => {
+ tools.value = []
+ currentTool.value = null
+ executionHistory.value = []
+ stats.value = {
+ total: 0,
+ byService: {},
+ recentExecutions: 0,
+ successfulExecutions: 0,
+ failedExecutions: 0
+ }
+ lastUpdateTime.value = null
+
+ // 重置新增状态
+ toolRecords.value = {
+ executions: [],
+ summary: {
+ total_executions: 0,
+ by_tool: {},
+ by_service: {}
+ }
+ }
+ currentExecutions.value.clear()
+ errors.value = []
+ lastError.value = null
+
+ // 重置加载状态
+ Object.keys(loadingStates.value).forEach(key => {
+ loadingStates.value[key] = false
+ })
+ loading.value = false
+ executing.value = false
+
+ console.log('🔄 Tools store reset')
+ }
+
+ return {
+ // 原有状态
+ tools,
+ currentTool,
+ executionHistory,
+ loading,
+ executing,
+ lastUpdateTime,
+ stats,
+
+ // 新增状态
+ toolRecords,
+ currentExecutions,
+ errors,
+ lastError,
+ loadingStates,
+ toolConfig,
+
+ // 原有计算属性
+ toolsByService,
+ serviceNames,
+ recentExecutions,
+ popularTools,
+
+ // 新增计算属性
+ isLoading,
+ hasErrors,
+ recentErrors,
+ isExecuting,
+ executionStats,
+ toolsByCategory,
+ availableTools,
+ favoriteTools,
+
+ // 原有方法
+ fetchTools,
+ executeTool,
+ getToolDetails,
+ getToolRecords,
+ updateStats,
+ setCurrentTool,
+ getToolByName,
+ getToolsByService,
+ searchTools,
+ clearExecutionHistory,
+ removeExecutionFromHistory,
+ resetStore,
+
+ // 新增方法
+ setLoadingState,
+ addError,
+ clearErrors,
+ fetchToolExecutionStats,
+ toggleToolFavorite,
+ loadFavoriteTools
+ }
+})
diff --git a/vue/src/styles/components.scss b/vue/src/styles/components.scss
new file mode 100644
index 00000000..cb2f9a18
--- /dev/null
+++ b/vue/src/styles/components.scss
@@ -0,0 +1,425 @@
+// 组件样式文件
+// 包含自定义组件样式和Element Plus组件样式覆盖
+
+// 导入变量和混入
+@import './variables.scss';
+@import './mixins.scss';
+
+// 页面布局样式
+.page-header {
+ @include flex-between;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--el-border-color-lighter);
+
+ .header-left {
+ .page-title {
+ margin: 0 0 4px 0;
+ font-size: 24px;
+ font-weight: var(--font-weight-medium);
+ color: var(--el-text-color-primary);
+ }
+
+ .page-description {
+ margin: 0;
+ font-size: 14px;
+ color: var(--el-text-color-secondary);
+ }
+ }
+
+ .header-right {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ }
+}
+
+// 统计卡片样式
+.stats-cards {
+ margin-bottom: 20px;
+
+ .stat-card {
+ @include card-shadow;
+ padding: 20px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+
+ .stat-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 8px;
+ @include flex-center;
+
+ &.services {
+ background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-5));
+ color: var(--el-color-primary);
+ }
+
+ &.tools {
+ background: linear-gradient(135deg, var(--el-color-success-light-7), var(--el-color-success-light-5));
+ color: var(--el-color-success);
+ }
+
+ &.agents {
+ background: linear-gradient(135deg, var(--el-color-warning-light-7), var(--el-color-warning-light-5));
+ color: var(--el-color-warning);
+ }
+
+ &.monitoring {
+ background: linear-gradient(135deg, var(--el-color-info-light-7), var(--el-color-info-light-5));
+ color: var(--el-color-info);
+ }
+ }
+
+ .stat-content {
+ flex: 1;
+
+ .stat-value {
+ font-size: 24px;
+ font-weight: var(--font-weight-bold);
+ color: var(--el-text-color-primary);
+ margin-bottom: 4px;
+ }
+
+ .stat-label {
+ font-size: 14px;
+ color: var(--el-text-color-secondary);
+ }
+ }
+ }
+}
+
+// 筛选卡片样式
+.filter-card {
+ margin-bottom: 20px;
+
+ .el-card__body {
+ padding: 16px 20px;
+ }
+}
+
+// 表格卡片样式
+.table-card {
+ .el-card__body {
+ padding: 0;
+ }
+
+ .el-table {
+ border: none;
+
+ .el-table__header {
+ th {
+ background-color: var(--el-fill-color-lighter);
+ border-bottom: 1px solid var(--el-border-color);
+ }
+ }
+
+ .el-table__body {
+ tr:hover {
+ background-color: var(--el-fill-color-light);
+ }
+ }
+ }
+}
+
+// 工具卡片样式
+.tools-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+
+ .tool-card {
+ @include card-shadow;
+ padding: 20px;
+ border: 1px solid var(--el-border-color-lighter);
+ transition: all 0.3s ease;
+
+ &:hover {
+ border-color: var(--el-color-primary-light-5);
+ transform: translateY(-2px);
+ }
+
+ .tool-header {
+ @include flex-between;
+ margin-bottom: 12px;
+
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: var(--font-weight-medium);
+ color: var(--el-text-color-primary);
+ }
+
+ .el-tag {
+ font-size: 12px;
+ }
+ }
+
+ .tool-description {
+ color: var(--el-text-color-regular);
+ font-size: 14px;
+ line-height: 1.5;
+ margin-bottom: 16px;
+ min-height: 42px;
+ @include text-ellipsis-multiline(3);
+ }
+
+ .tool-meta {
+ @include flex-between;
+ margin-bottom: 16px;
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+
+ .param-count {
+ @include flex-center;
+ gap: 4px;
+ }
+ }
+
+ .tool-actions {
+ @include flex-between;
+ gap: 8px;
+
+ .el-button {
+ flex: 1;
+ }
+ }
+ }
+}
+
+// 服务分组样式
+.service-group {
+ margin-bottom: 24px;
+
+ .group-header {
+ @include flex-between;
+ padding: 12px 16px;
+ background-color: var(--el-fill-color-lighter);
+ border-radius: 6px 6px 0 0;
+ border: 1px solid var(--el-border-color-lighter);
+ border-bottom: none;
+
+ .group-title {
+ font-weight: var(--font-weight-medium);
+ color: var(--el-text-color-primary);
+
+ .service-icon {
+ margin-right: 8px;
+
+ &.local {
+ color: var(--el-color-success);
+ }
+
+ &.remote {
+ color: var(--el-color-info);
+ }
+ }
+ }
+
+ .group-count {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+ background-color: var(--el-fill-color);
+ padding: 2px 8px;
+ border-radius: 10px;
+ }
+ }
+
+ .group-content {
+ border: 1px solid var(--el-border-color-lighter);
+ border-top: none;
+ border-radius: 0 0 6px 6px;
+ }
+}
+
+// 执行结果样式
+.execution-result {
+ .result-header {
+ @include flex-between;
+ margin-bottom: 12px;
+
+ .result-status {
+ @include flex-center;
+ gap: 8px;
+ font-weight: var(--font-weight-medium);
+
+ &.success {
+ color: var(--el-color-success);
+ }
+
+ &.error {
+ color: var(--el-color-danger);
+ }
+ }
+ }
+
+ .result-content {
+ background-color: var(--el-fill-color-light);
+ border-radius: 6px;
+ padding: 16px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ max-height: 400px;
+ overflow-y: auto;
+ @include custom-scrollbar;
+
+ pre {
+ margin: 0;
+ padding: 0;
+ background: none;
+ white-space: pre-wrap;
+ word-break: break-all;
+ }
+ }
+}
+
+// 监控图表样式
+.monitoring-charts {
+ .chart-container {
+ height: 300px;
+ margin-bottom: 20px;
+ }
+
+ .chart-title {
+ font-size: 16px;
+ font-weight: var(--font-weight-medium);
+ margin-bottom: 12px;
+ color: var(--el-text-color-primary);
+ }
+}
+
+// 系统日志样式
+.system-logs {
+ .log-entry {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--el-border-color-lighter);
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .log-time {
+ color: var(--el-text-color-secondary);
+ margin-right: 12px;
+ }
+
+ .log-level {
+ margin-right: 12px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: var(--font-weight-medium);
+
+ &.info {
+ background-color: var(--el-color-info-light-8);
+ color: var(--el-color-info);
+ }
+
+ &.warning {
+ background-color: var(--el-color-warning-light-8);
+ color: var(--el-color-warning);
+ }
+
+ &.error {
+ background-color: var(--el-color-danger-light-8);
+ color: var(--el-color-danger);
+ }
+
+ &.success {
+ background-color: var(--el-color-success-light-8);
+ color: var(--el-color-success);
+ }
+ }
+
+ .log-message {
+ color: var(--el-text-color-primary);
+ }
+ }
+}
+
+// Element Plus 组件样式覆盖
+.el-card {
+ border: 1px solid var(--el-border-color-lighter);
+ box-shadow: var(--box-shadow-light);
+
+ .el-card__header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--el-border-color-lighter);
+ background-color: var(--el-fill-color-lighter);
+ }
+
+ .el-card__body {
+ padding: 20px;
+ }
+}
+
+.el-button {
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
+
+.el-table {
+ .el-table__cell {
+ padding: 12px 0;
+ }
+}
+
+.el-dialog {
+ .el-dialog__header {
+ padding: 20px 20px 10px;
+ border-bottom: 1px solid var(--el-border-color-lighter);
+ }
+
+ .el-dialog__body {
+ padding: 20px;
+ }
+
+ .el-dialog__footer {
+ padding: 10px 20px 20px;
+ border-top: 1px solid var(--el-border-color-lighter);
+ }
+}
+
+// 响应式适配
+@include respond-to(xs) {
+ .page-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+
+ .header-right {
+ width: 100%;
+ justify-content: flex-end;
+ }
+ }
+
+ .stats-cards {
+ .stat-card {
+ padding: 16px;
+
+ .stat-icon {
+ width: 40px;
+ height: 40px;
+ }
+
+ .stat-content .stat-value {
+ font-size: 20px;
+ }
+ }
+ }
+
+ .tools-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/vue/src/styles/global.scss b/vue/src/styles/global.scss
new file mode 100644
index 00000000..c25c1874
--- /dev/null
+++ b/vue/src/styles/global.scss
@@ -0,0 +1,187 @@
+// MCPStore Vue Frontend - Global Styles (Atomic / Minimalist)
+@import './variables.scss';
+
+// --- Reset & Base ---
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ color: var(--text-primary);
+ background-color: var(--bg-body);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+// --- Atomic Layout Utilities ---
+.page-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--space-6) var(--space-4);
+ width: 100%;
+}
+
+// --- Typography ---
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 600;
+ color: var(--text-primary);
+ letter-spacing: -0.02em; // 收紧字间距,更现代
+}
+
+.text-primary { color: var(--text-primary) !important; }
+.text-secondary { color: var(--text-secondary) !important; }
+.text-accent { color: var(--color-accent) !important; }
+.text-mono { font-family: var(--font-mono) !important; }
+
+// --- Components ---
+
+// Buttons (Flat & Minimal)
+.atom-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 12px;
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &.primary {
+ background-color: var(--color-primary);
+ color: #fff;
+ border: 1px solid transparent;
+ &:hover { background-color: var(--color-primary-hover); }
+ }
+
+ &.ghost {
+ background-color: transparent;
+ color: var(--text-regular);
+ border: 1px solid transparent;
+ &:hover { background-color: var(--bg-hover); color: var(--text-primary); }
+ }
+
+ &.outline {
+ background-color: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ &:hover { border-color: var(--text-secondary); }
+ }
+}
+
+// Tables (Clean lines)
+.atom-table {
+ width: 100%;
+ border-collapse: collapse;
+
+ th, td {
+ padding: 12px 16px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ th {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ font-weight: 600;
+ background-color: transparent; // No background for headers
+ }
+
+ td {
+ font-size: 14px;
+ color: var(--text-regular);
+ }
+
+ tr:last-child td {
+ border-bottom: none;
+ }
+
+ tr:hover td {
+ background-color: var(--bg-hover); // Subtle hover effect
+ }
+}
+
+// Tags / Badges (Pill shape, subtle colors)
+.atom-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px; // Pill
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.2;
+
+ &.success { background: rgba(16, 185, 129, 0.1); color: var(--color-success); }
+ &.warning { background: rgba(245, 158, 11, 0.1); color: var(--color-warning); }
+ &.danger { background: rgba(239, 68, 68, 0.1); color: var(--color-danger); }
+ &.neutral { background: var(--bg-hover); color: var(--text-secondary); }
+}
+
+// Inputs (Minimal borders)
+input.atom-input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: border-color 0.2s;
+
+ &:focus {
+ outline: none;
+ border-color: var(--text-secondary); // Darker grey on focus
+ }
+
+ &::placeholder {
+ color: var(--text-placeholder);
+ }
+}
+
+// --- Utilities ---
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: 8px; }
+.gap-4 { gap: 16px; }
+
+.w-full { width: 100%; }
+.h-full { height: 100%; }
+
+.border-b { border-bottom: 1px solid var(--border-color); }
+.border { border: 1px solid var(--border-color); }
+.rounded { border-radius: var(--radius-lg); }
+.bg-surface { background-color: var(--bg-surface); }
+
+.p-4 { padding: 16px; }
+.p-6 { padding: 24px; }
+.mb-4 { margin-bottom: 16px; }
+.mt-4 { margin-top: 16px; }
+
+// Scrollbar (Minimal)
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border-color-dark);
+ border-radius: 3px;
+ &:hover { background: var(--text-secondary); }
+}
diff --git a/vue/src/styles/index.scss b/vue/src/styles/index.scss
new file mode 100644
index 00000000..ccc65d5b
--- /dev/null
+++ b/vue/src/styles/index.scss
@@ -0,0 +1,793 @@
+// MCPStore Vue Frontend - 全局样式
+@use './variables.scss' as *;
+
+// CSS Reset - 现代化重置
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+}
+
+html {
+ height: 100%;
+ font-size: var(--font-size-base);
+ line-height: var(--line-height-base);
+ -webkit-text-size-adjust: 100%;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: "rlig" 1, "calt" 1;
+}
+
+body {
+ height: 100%;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-base);
+ color: var(--text-primary);
+ background-color: var(--bg-color-page);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ transition: var(--transition-normal);
+}
+
+#app {
+ height: 100%;
+ position: relative;
+}
+
+// 基础排版优化
+h1, h2, h3, h4, h5, h6 {
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-tight);
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-4);
+}
+
+h1 { font-size: var(--font-size-4xl); }
+h2 { font-size: var(--font-size-3xl); }
+h3 { font-size: var(--font-size-2xl); }
+h4 { font-size: var(--font-size-xl); }
+h5 { font-size: var(--font-size-lg); }
+h6 { font-size: var(--font-size-md); }
+
+p {
+ margin-bottom: var(--spacing-4);
+ line-height: var(--line-height-relaxed);
+}
+
+a {
+ color: var(--primary-color);
+ text-decoration: none;
+ transition: var(--transition-fast);
+
+ &:hover {
+ color: var(--primary-dark);
+ text-decoration: underline;
+ }
+
+ &:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+ }
+}
+
+code, pre {
+ font-family: var(--font-family-mono);
+ font-size: 0.875em;
+}
+
+code {
+ background-color: var(--bg-color-secondary);
+ padding: 0.125rem 0.375rem;
+ border-radius: var(--border-radius-sm);
+ color: var(--danger-color);
+}
+
+pre {
+ background-color: var(--bg-color-secondary);
+ padding: var(--spacing-4);
+ border-radius: var(--border-radius-md);
+ overflow-x: auto;
+ line-height: var(--line-height-base);
+}
+
+// 按钮重置和增强
+button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: inherit;
+
+ &:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: var(--opacity-50);
+ }
+}
+
+// 输入框增强
+input, textarea, select {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+
+ &:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+ }
+}
+
+// 滚动条样式 - 现代化设计
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-color-secondary);
+ border-radius: var(--border-radius-full);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--text-placeholder);
+ border-radius: var(--border-radius-full);
+ transition: var(--transition-fast);
+
+ &:hover {
+ background: var(--text-secondary);
+ }
+}
+
+// 暗色模式滚动条
+.dark {
+ ::-webkit-scrollbar-track {
+ background: var(--bg-color-tertiary);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--text-placeholder);
+
+ &:hover {
+ background: var(--text-secondary);
+ }
+ }
+}
+
+// 通用工具类 - 现代化布局
+.flex {
+ display: flex;
+}
+
+.flex-inline {
+ display: inline-flex;
+}
+
+.flex-center {
+ @include flex-center;
+}
+
+.flex-between {
+ @include flex-between;
+}
+
+.flex-column {
+ display: flex;
+ flex-direction: column;
+}
+
+.flex-row-reverse {
+ flex-direction: row-reverse;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.flex-nowrap {
+ flex-wrap: nowrap;
+}
+
+.flex-1 {
+ flex: 1 1 0%;
+}
+
+.flex-auto {
+ flex: 1 1 auto;
+}
+
+.flex-none {
+ flex: none;
+}
+
+.flex-shrink-0 {
+ flex-shrink: 0;
+}
+
+.flex-grow {
+ flex-grow: 1;
+}
+
+// Flex 对齐
+.items-start { align-items: flex-start; }
+.items-end { align-items: flex-end; }
+.items-center { align-items: center; }
+.items-stretch { align-items: stretch; }
+.items-baseline { align-items: baseline; }
+
+.justify-start { justify-content: flex-start; }
+.justify-end { justify-content: flex-end; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+.justify-around { justify-content: space-around; }
+.justify-evenly { justify-content: space-evenly; }
+
+// Gap 工具类
+.gap-1 { gap: var(--spacing-1); }
+.gap-2 { gap: var(--spacing-2); }
+.gap-3 { gap: var(--spacing-3); }
+.gap-4 { gap: var(--spacing-4); }
+.gap-5 { gap: var(--spacing-5); }
+.gap-6 { gap: var(--spacing-6); }
+.gap-8 { gap: var(--spacing-8); }
+.gap-10 { gap: var(--spacing-10); }
+.gap-12 { gap: var(--spacing-12); }
+
+// 文本工具类
+.text-center {
+ text-align: center;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-justify {
+ text-align: justify;
+}
+
+.text-start {
+ text-align: start;
+}
+
+.text-end {
+ text-align: end;
+}
+
+.text-ellipsis {
+ @include text-ellipsis;
+}
+
+.text-break {
+ word-wrap: break-word;
+ word-break: break-word;
+ hyphens: auto;
+}
+
+.text-nowrap {
+ white-space: nowrap;
+}
+
+// 字体粗细
+.font-light { font-weight: var(--font-weight-light); }
+.font-normal { font-weight: var(--font-weight-normal); }
+.font-medium { font-weight: var(--font-weight-medium); }
+.font-semibold { font-weight: var(--font-weight-semibold); }
+.font-bold { font-weight: var(--font-weight-bold); }
+.font-extrabold { font-weight: var(--font-weight-extrabold); }
+
+// 字体大小
+.text-xs { font-size: var(--font-size-xs); }
+.text-sm { font-size: var(--font-size-sm); }
+.text-base { font-size: var(--font-size-base); }
+.text-md { font-size: var(--font-size-md); }
+.text-lg { font-size: var(--font-size-lg); }
+.text-xl { font-size: var(--font-size-xl); }
+.text-2xl { font-size: var(--font-size-2xl); }
+.text-3xl { font-size: var(--font-size-3xl); }
+.text-4xl { font-size: var(--font-size-4xl); }
+.text-5xl { font-size: var(--font-size-5xl); }
+
+// 行高
+.leading-tight { line-height: var(--line-height-tight); }
+.leading-snug { line-height: var(--line-height-snug); }
+.leading-normal { line-height: var(--line-height-base); }
+.leading-relaxed { line-height: var(--line-height-relaxed); }
+.leading-loose { line-height: var(--line-height-loose); }
+
+.clearfix {
+ @include clearfix;
+}
+
+// 间距工具类
+@each $size in (xs, sm, md, lg, xl, xxl) {
+ .m-#{$size} {
+ margin: var(--spacing-#{$size});
+ }
+
+ .mt-#{$size} {
+ margin-top: var(--spacing-#{$size});
+ }
+
+ .mr-#{$size} {
+ margin-right: var(--spacing-#{$size});
+ }
+
+ .mb-#{$size} {
+ margin-bottom: var(--spacing-#{$size});
+ }
+
+ .ml-#{$size} {
+ margin-left: var(--spacing-#{$size});
+ }
+
+ .mx-#{$size} {
+ margin-left: var(--spacing-#{$size});
+ margin-right: var(--spacing-#{$size});
+ }
+
+ .my-#{$size} {
+ margin-top: var(--spacing-#{$size});
+ margin-bottom: var(--spacing-#{$size});
+ }
+
+ .p-#{$size} {
+ padding: var(--spacing-#{$size});
+ }
+
+ .pt-#{$size} {
+ padding-top: var(--spacing-#{$size});
+ }
+
+ .pr-#{$size} {
+ padding-right: var(--spacing-#{$size});
+ }
+
+ .pb-#{$size} {
+ padding-bottom: var(--spacing-#{$size});
+ }
+
+ .pl-#{$size} {
+ padding-left: var(--spacing-#{$size});
+ }
+
+ .px-#{$size} {
+ padding-left: var(--spacing-#{$size});
+ padding-right: var(--spacing-#{$size});
+ }
+
+ .py-#{$size} {
+ padding-top: var(--spacing-#{$size});
+ padding-bottom: var(--spacing-#{$size});
+ }
+}
+
+// 文字颜色工具类
+.text-primary {
+ color: var(--text-primary);
+}
+
+.text-regular {
+ color: var(--text-regular);
+}
+
+.text-secondary {
+ color: var(--text-secondary);
+}
+
+.text-placeholder {
+ color: var(--text-placeholder);
+}
+
+.text-success {
+ color: var(--success-color);
+}
+
+.text-warning {
+ color: var(--warning-color);
+}
+
+.text-danger {
+ color: var(--danger-color);
+}
+
+.text-info {
+ color: var(--info-color);
+}
+
+// 背景色工具类
+.bg-primary {
+ background-color: var(--primary-color);
+}
+
+.bg-success {
+ background-color: var(--success-color);
+}
+
+.bg-warning {
+ background-color: var(--warning-color);
+}
+
+.bg-danger {
+ background-color: var(--danger-color);
+}
+
+.bg-info {
+ background-color: var(--info-color);
+}
+
+// 卡片样式
+.card {
+ @include card-shadow;
+ padding: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
+
+ &.hover-shadow {
+ @include hover-shadow;
+ }
+
+ .card-header {
+ @include flex-between;
+ margin-bottom: var(--spacing-md);
+ padding-bottom: var(--spacing-sm);
+ border-bottom: 1px solid var(--border-lighter);
+
+ .card-title {
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-primary);
+ }
+ }
+
+ .card-body {
+ flex: 1;
+ }
+
+ .card-footer {
+ margin-top: var(--spacing-md);
+ padding-top: var(--spacing-sm);
+ border-top: 1px solid var(--border-lighter);
+ }
+}
+
+// 状态指示器
+.status-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ font-size: var(--font-size-sm);
+
+ .status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--border-radius-circle);
+
+ &.success {
+ background-color: var(--success-color);
+ }
+
+ &.warning {
+ background-color: var(--warning-color);
+ }
+
+ &.danger {
+ background-color: var(--danger-color);
+ }
+
+ &.info {
+ background-color: var(--info-color);
+ }
+ }
+}
+
+// 加载状态
+.loading-container {
+ @include flex-center;
+ min-height: 200px;
+ color: var(--text-secondary);
+}
+
+// 空状态
+.empty-container {
+ @include flex-center;
+ flex-direction: column;
+ min-height: 200px;
+ color: var(--text-secondary);
+
+ .empty-icon {
+ font-size: 48px;
+ margin-bottom: var(--spacing-md);
+ opacity: 0.5;
+ }
+
+ .empty-text {
+ font-size: var(--font-size-lg);
+ margin-bottom: var(--spacing-sm);
+ }
+
+ .empty-description {
+ font-size: var(--font-size-sm);
+ color: var(--text-placeholder);
+ }
+}
+
+// 响应式隐藏
+@include respond-to(xs) {
+ .hidden-xs {
+ display: none !important;
+ }
+}
+
+@include respond-to(sm) {
+ .hidden-sm-and-up {
+ display: none !important;
+ }
+}
+
+@include respond-to(md) {
+ .hidden-md-and-up {
+ display: none !important;
+ }
+}
+
+@include respond-to(lg) {
+ .hidden-lg-and-up {
+ display: none !important;
+ }
+}
+
+// Element Plus 样式覆盖
+.el-button {
+ transition: var(--transition-base);
+}
+
+.el-card {
+ border: 1px solid var(--border-lighter);
+ box-shadow: var(--shadow-base);
+
+ &:hover {
+ box-shadow: var(--shadow-light);
+ }
+}
+
+.el-table {
+ .el-table__header {
+ background-color: var(--bg-color-page);
+ }
+}
+
+.el-menu {
+ border-right: none;
+}
+
+/* ------------------------------ */
+/* 页面级布局辅助类 */
+/* ------------------------------ */
+
+.page-container {
+ width: 100%;
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 24px 32px 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.page-container--wide {
+ max-width: 1440px;
+}
+
+.page-container--narrow {
+ max-width: 960px;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.page-header__title-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.page-header__title {
+ margin: 0;
+ font-size: 28px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.page-header__subtitle {
+ margin: 0;
+ font-size: 16px;
+ color: var(--text-secondary);
+}
+
+.page-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.page-section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.page-section + .page-section {
+ margin-top: 8px;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.section-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.section-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.section-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.content-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.content-grid--two-column {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.content-grid--three-column {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.content-grid--auto {
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.content-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.content-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
+ gap: 16px;
+}
+
+.content-columns--balanced {
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+}
+
+.card-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+.card-group--dense {
+ gap: 12px;
+}
+
+.sticky-section {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: var(--el-bg-color);
+}
+
+@include respond-to(lg) {
+ .page-container {
+ padding: 24px;
+ max-width: 1180px;
+ }
+
+ .content-columns {
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ }
+}
+
+@include respond-to(md) {
+ .page-container {
+ padding: 20px;
+ gap: 20px;
+ }
+
+ .page-header__title {
+ font-size: 26px;
+ }
+
+ .content-grid--three-column {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@include respond-to(sm) {
+ .page-container {
+ padding: 16px;
+ }
+
+ .page-header__title {
+ font-size: 24px;
+ }
+
+ .page-header__actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .content-grid--two-column,
+ .content-grid--three-column,
+ .content-columns {
+ grid-template-columns: 1fr;
+ }
+}
+
+// 自定义动画
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.fade-in {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.slide-in-right {
+ animation: slideInRight 0.3s ease-out;
+}
diff --git a/vue/src/styles/mixins.scss b/vue/src/styles/mixins.scss
new file mode 100644
index 00000000..f257aeb8
--- /dev/null
+++ b/vue/src/styles/mixins.scss
@@ -0,0 +1,265 @@
+// SCSS混入文件
+// 提供常用的样式混入和工具函数
+
+// 卡片样式混入
+@mixin card-shadow {
+ background: var(--el-bg-color);
+ border-radius: var(--border-radius-md);
+ box-shadow: var(--box-shadow-light);
+ transition: var(--transition-base);
+
+ &:hover {
+ box-shadow: var(--box-shadow-medium);
+ }
+}
+
+// 响应式混入
+@mixin mobile {
+ @media (max-width: 768px) {
+ @content;
+ }
+}
+
+@mixin tablet {
+ @media (max-width: 1024px) {
+ @content;
+ }
+}
+
+@mixin desktop {
+ @media (min-width: 1025px) {
+ @content;
+ }
+}
+
+// 响应式断点混入
+@mixin respond-to($breakpoint) {
+ @if $breakpoint == xs {
+ @media (max-width: 575px) {
+ @content;
+ }
+ }
+ @if $breakpoint == sm {
+ @media (min-width: 576px) and (max-width: 767px) {
+ @content;
+ }
+ }
+ @if $breakpoint == md {
+ @media (min-width: 768px) and (max-width: 991px) {
+ @content;
+ }
+ }
+ @if $breakpoint == lg {
+ @media (min-width: 992px) and (max-width: 1199px) {
+ @content;
+ }
+ }
+ @if $breakpoint == xl {
+ @media (min-width: 1200px) {
+ @content;
+ }
+ }
+}
+
+// 文本省略混入
+@mixin text-ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@mixin text-ellipsis-multiline($lines: 2) {
+ display: -webkit-box;
+ -webkit-line-clamp: $lines;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+// Flex布局混入
+@mixin flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin flex-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+@mixin flex-start {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+@mixin flex-end {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+@mixin flex-column {
+ display: flex;
+ flex-direction: column;
+}
+
+@mixin flex-column-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+// 按钮样式混入
+@mixin button-variant($color, $background, $border) {
+ color: $color;
+ background-color: $background;
+ border-color: $border;
+
+ &:hover {
+ color: darken($color, 10%);
+ background-color: darken($background, 10%);
+ border-color: darken($border, 10%);
+ }
+
+ &:active {
+ color: darken($color, 15%);
+ background-color: darken($background, 15%);
+ border-color: darken($border, 15%);
+ }
+}
+
+// 输入框样式混入
+@mixin input-focus($color: var(--el-color-primary)) {
+ &:focus {
+ border-color: $color;
+ box-shadow: 0 0 0 2px rgba($color, 0.2);
+ }
+}
+
+// 滚动条样式混入
+@mixin custom-scrollbar($width: 6px, $track-color: var(--el-fill-color-lighter), $thumb-color: var(--el-border-color-darker)) {
+ &::-webkit-scrollbar {
+ width: $width;
+ height: $width;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: $track-color;
+ border-radius: calc($width / 2);
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: $thumb-color;
+ border-radius: calc($width / 2);
+
+ &:hover {
+ background: darken($thumb-color, 10%);
+ }
+ }
+}
+
+// 动画混入
+@mixin fade-in($duration: 0.3s) {
+ animation: fadeIn $duration ease-in-out;
+}
+
+@mixin slide-in-up($duration: 0.3s) {
+ animation: slideInUp $duration ease-out;
+}
+
+@mixin slide-in-down($duration: 0.3s) {
+ animation: slideInDown $duration ease-out;
+}
+
+// 阴影混入
+@mixin box-shadow-light {
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+@mixin box-shadow-medium {
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+}
+
+@mixin box-shadow-heavy {
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
+}
+
+// 边框混入
+@mixin border-radius($radius: var(--border-radius-md)) {
+ border-radius: $radius;
+}
+
+// 过渡动画混入
+@mixin transition($property: all, $duration: 0.3s, $timing: ease) {
+ transition: $property $duration $timing;
+}
+
+// 清除浮动混入
+@mixin clearfix {
+ &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+}
+
+// 绝对定位居中混入
+@mixin absolute-center {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+// 固定宽高比混入
+@mixin aspect-ratio($width, $height) {
+ position: relative;
+
+ &::before {
+ content: "";
+ display: block;
+ padding-top: percentage($height / $width);
+ }
+
+ > * {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+// 隐藏文本混入
+@mixin hide-text {
+ text-indent: -9999px;
+ overflow: hidden;
+ text-decoration: none;
+ text-align: left;
+ font-size: 0;
+ white-space: nowrap;
+}
+
+// 重置列表样式混入
+@mixin reset-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+// 重置按钮样式混入
+@mixin reset-button {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+ text-decoration: none;
+ outline: none;
+}
diff --git a/vue/src/styles/theme.scss b/vue/src/styles/theme.scss
new file mode 100644
index 00000000..aa1b1c38
--- /dev/null
+++ b/vue/src/styles/theme.scss
@@ -0,0 +1,15 @@
+/* Minimal theme variables for Element Plus primary color. Extend as needed. */
+:root {
+ /* Primary brand color */
+ --el-color-primary: #409EFF;
+ /* Unified subtle radius (slightly rounded, closer to square) */
+ --el-border-radius-base: 3px;
+ --el-border-radius-small: 2px;
+}
+
+/* Optional: add dark-mode specific overrides here if desired */
+html.dark {
+ /* Example: tweak primary shade in dark if needed */
+ /* --el-color-primary: #66b1ff; */
+}
+
diff --git a/vue/src/styles/variables.scss b/vue/src/styles/variables.scss
new file mode 100644
index 00000000..6bd2a268
--- /dev/null
+++ b/vue/src/styles/variables.scss
@@ -0,0 +1,232 @@
+// MCPStore Vue Frontend - SCSS Variables (Atomic / Minimalist Style)
+@use 'sass:map';
+
+:root {
+ // --- 核心色调 (Monochrome & Accent) ---
+ --color-primary: #000000; // 极致黑作为主操作色
+ --color-primary-hover: #333333;
+ --color-accent: #2563EB; // 科技蓝作为点缀 (Inter/System blue)
+
+ // --- 语义色 (低饱和度,不刺眼) ---
+ --color-success: #10B981;
+ --color-warning: #F59E0B;
+ --color-danger: #EF4444;
+ --color-info: #6B7280;
+
+ // --- 文本系统 (Inter font stack style) ---
+ --text-primary: #111827; // 接近纯黑
+ --text-regular: #374151; // 深灰
+ --text-secondary: #6B7280; // 中灰
+ --text-placeholder: #9CA3AF; // 浅灰
+ --text-disabled: #D1D5DB;
+
+ // --- 背景系统 (Clean & Flat) ---
+ --bg-body: #FAFAFA; // 极浅灰背景,避免纯白刺眼
+ --bg-surface: #FFFFFF; // 卡片/内容区纯白
+ --bg-hover: #F3F4F6; // 悬停态
+ --bg-active: #E5E7EB; // 激活态
+
+ // Element Plus 使用的背景变量映射
+ --bg-color-page: #FAFAFA;
+ --bg-color-secondary: #F3F4F6;
+ --bg-color-tertiary: #E5E7EB;
+
+ // --- 边框系统 (Thin & Crisp) ---
+ --border-color: #E5E7EB; // 极细灰色边框
+ --border-color-light: #F3F4F6;
+ --border-color-dark: #D1D5DB;
+
+ // 兼容旧变量
+ --border-lighter: #E5E7EB;
+
+ // --- 阴影 (Subtle or None) ---
+ // 原子风通常极少使用阴影,或者使用极淡的扩散阴影
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
+
+ // --- 倒角 (Refined) ---
+ --radius-sm: 4px;
+ --radius-md: 6px; // 标准倒角
+ --radius-lg: 8px; // 卡片倒角
+ --radius-full: 9999px;
+
+ // 兼容旧变量
+ --border-radius-sm: 4px;
+ --border-radius-md: 6px;
+ --border-radius-lg: 8px;
+ --border-radius-circle: 50%;
+ --border-radius-full: 9999px;
+
+ // --- 间距 (Spacious) ---
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ --space-12: 48px;
+
+ // 兼容旧变量
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --spacing-xl: 32px;
+ --spacing-1: 4px;
+ --spacing-2: 8px;
+ --spacing-3: 12px;
+ --spacing-4: 16px;
+ --spacing-5: 20px;
+ --spacing-6: 24px;
+ --spacing-8: 32px;
+ --spacing-10: 40px;
+ --spacing-12: 48px;
+
+ // --- 字体 ---
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+
+ // 兼容旧变量
+ --font-family-sans: var(--font-sans);
+ --font-family-mono: var(--font-mono);
+
+ --font-size-xs: 12px;
+ --font-size-sm: 14px;
+ --font-size-base: 14px;
+ --font-size-md: 16px;
+ --font-size-lg: 18px;
+ --font-size-xl: 20px;
+ --font-size-2xl: 24px;
+ --font-size-3xl: 30px;
+ --font-size-4xl: 36px;
+ --font-size-5xl: 48px;
+
+ --font-weight-light: 300;
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --font-weight-extrabold: 800;
+
+ --line-height-tight: 1.25;
+ --line-height-snug: 1.375;
+ --line-height-base: 1.5;
+ --line-height-relaxed: 1.625;
+ --line-height-loose: 2;
+
+ --transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-normal: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-base: var(--transition-normal);
+
+ --opacity-50: 0.5;
+}
+
+// 暗色模式 (Dark Mode - Minimalist)
+:root.dark {
+ --color-primary: #FFFFFF;
+ --color-primary-hover: #E5E5E5;
+
+ --text-primary: #F9FAFB;
+ --text-regular: #D1D5DB;
+ --text-secondary: #9CA3AF;
+ --text-placeholder: #6B7280;
+
+ --bg-body: #000000; // 纯黑背景 (OLED style)
+ --bg-surface: #111111; // 极深灰卡片
+ --bg-hover: #1F2937;
+
+ --bg-color-page: #000000;
+ --bg-color-secondary: #1F2937;
+ --bg-color-tertiary: #111111;
+
+ --border-color: #333333; // 深色边框
+ --border-color-light: #1F1F1F;
+ --border-lighter: #333333;
+}
+
+// Element Plus 覆盖变量
+$primary-color: #000000;
+$text-color-primary: #111827;
+$bg-color: #FFFFFF;
+$border-color-base: #E5E7EB;
+
+// Breakpoints
+$breakpoint-xs: 480px;
+$breakpoint-sm: 768px;
+$breakpoint-md: 992px;
+$breakpoint-lg: 1200px;
+$breakpoint-xl: 1600px;
+
+// --- Mixins (恢复必要的 Mixins 以修复编译错误) ---
+
+@mixin flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin flex-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+@mixin text-ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+@mixin clearfix {
+ &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+}
+
+@mixin card-shadow {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+@mixin hover-shadow {
+ transition: all 0.2s ease;
+ &:hover {
+ border-color: var(--border-color-dark);
+ // box-shadow: var(--shadow-md);
+ }
+}
+
+@mixin respond-to($breakpoint) {
+ @if $breakpoint == xs {
+ @media (max-width: #{$breakpoint-xs - 1px}) { @content; }
+ }
+ @if $breakpoint == sm {
+ @media (min-width: #{$breakpoint-sm}) { @content; }
+ }
+ @if $breakpoint == md {
+ @media (min-width: #{$breakpoint-md}) { @content; }
+ }
+ @if $breakpoint == lg {
+ @media (min-width: #{$breakpoint-lg}) { @content; }
+ }
+}
+
+// 极简卡片混合
+@mixin atom-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+
+ &:hover {
+ border-color: var(--border-color-dark);
+ }
+}
diff --git a/vue/src/utils/constants.js b/vue/src/utils/constants.js
new file mode 100644
index 00000000..1b883c49
--- /dev/null
+++ b/vue/src/utils/constants.js
@@ -0,0 +1,264 @@
+/**
+ * 常量定义
+ */
+
+// API相关常量
+export const API_CONFIG = {
+ BASE_URL: import.meta.env.VITE_API_BASE_URL,
+ TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT) || 30000,
+ RETRY_TIMES: 3,
+ RETRY_DELAY: 1000
+}
+
+// 存储键名
+export const STORAGE_KEYS = {
+ TOKEN: 'mcpstore-token',
+ USER_INFO: 'mcpstore-user',
+ THEME: 'mcpstore-theme',
+ LANGUAGE: 'mcpstore-language',
+ SIDEBAR_COLLAPSE: 'mcpstore-collapse',
+ RECENT_SERVICES: 'mcpstore-recent-services',
+ RECENT_TOOLS: 'mcpstore-recent-tools'
+}
+
+// 主题配置
+export const THEME_CONFIG = {
+ LIGHT: 'light',
+ DARK: 'dark',
+ AUTO: 'auto'
+}
+
+// 语言配置
+export const LANGUAGE_CONFIG = {
+ ZH_CN: 'zh-CN',
+ EN_US: 'en-US'
+}
+
+// 🔧 服务生命周期状态 - 7状态系统(2025-07-31更新)
+export const SERVICE_STATUS = {
+ INITIALIZING: 'initializing', // 初始化中
+ HEALTHY: 'healthy', // 健康
+ WARNING: 'warning', // 警告(响应慢但正常)
+ RECONNECTING: 'reconnecting', // 重连中
+ UNREACHABLE: 'unreachable', // 不可达
+ DISCONNECTING: 'disconnecting', // 断开连接中
+ DISCONNECTED: 'disconnected' // 已断开
+}
+
+// 🔧 服务状态映射 - 7状态系统
+export const SERVICE_STATUS_MAP = {
+ [SERVICE_STATUS.INITIALIZING]: '初始化中',
+ [SERVICE_STATUS.HEALTHY]: '健康',
+ [SERVICE_STATUS.WARNING]: '警告',
+ [SERVICE_STATUS.RECONNECTING]: '重连中',
+ [SERVICE_STATUS.UNREACHABLE]: '不可达',
+ [SERVICE_STATUS.DISCONNECTING]: '断开中',
+ [SERVICE_STATUS.DISCONNECTED]: '已断开'
+}
+
+// 🔧 服务状态颜色 - 7状态系统
+export const SERVICE_STATUS_COLORS = {
+ [SERVICE_STATUS.INITIALIZING]: 'primary',
+ [SERVICE_STATUS.HEALTHY]: 'success',
+ [SERVICE_STATUS.WARNING]: 'warning',
+ [SERVICE_STATUS.RECONNECTING]: 'primary',
+ [SERVICE_STATUS.UNREACHABLE]: 'danger',
+ [SERVICE_STATUS.DISCONNECTING]: 'warning',
+ [SERVICE_STATUS.DISCONNECTED]: 'info'
+}
+
+// 工具执行状态
+export const TOOL_EXECUTION_STATUS = {
+ PENDING: 'pending',
+ RUNNING: 'running',
+ SUCCESS: 'success',
+ FAILED: 'failed',
+ ERROR: 'failed',
+ TIMEOUT: 'timeout'
+}
+
+// 工具执行状态映射
+export const TOOL_EXECUTION_STATUS_MAP = {
+ [TOOL_EXECUTION_STATUS.PENDING]: '等待中',
+ [TOOL_EXECUTION_STATUS.RUNNING]: '执行中',
+ [TOOL_EXECUTION_STATUS.SUCCESS]: '成功',
+ [TOOL_EXECUTION_STATUS.FAILED]: '失败',
+ [TOOL_EXECUTION_STATUS.ERROR]: '失败',
+ [TOOL_EXECUTION_STATUS.TIMEOUT]: '超时'
+}
+
+// 工具执行状态颜色
+export const TOOL_EXECUTION_STATUS_COLORS = {
+ [TOOL_EXECUTION_STATUS.PENDING]: 'info',
+ [TOOL_EXECUTION_STATUS.RUNNING]: 'warning',
+ [TOOL_EXECUTION_STATUS.SUCCESS]: 'success',
+ [TOOL_EXECUTION_STATUS.FAILED]: 'danger',
+ [TOOL_EXECUTION_STATUS.ERROR]: 'danger',
+ [TOOL_EXECUTION_STATUS.TIMEOUT]: 'danger'
+}
+
+// Agent状态
+export const AGENT_STATUS = {
+ ACTIVE: 'active',
+ INACTIVE: 'inactive',
+ BUSY: 'busy',
+ ERROR: 'error'
+}
+
+// Agent状态映射
+export const AGENT_STATUS_MAP = {
+ [AGENT_STATUS.ACTIVE]: '活跃',
+ [AGENT_STATUS.INACTIVE]: '非活跃',
+ [AGENT_STATUS.BUSY]: '忙碌',
+ [AGENT_STATUS.ERROR]: '错误'
+}
+
+// Agent状态颜色
+export const AGENT_STATUS_COLORS = {
+ [AGENT_STATUS.ACTIVE]: 'success',
+ [AGENT_STATUS.INACTIVE]: 'info',
+ [AGENT_STATUS.BUSY]: 'warning',
+ [AGENT_STATUS.ERROR]: 'danger'
+}
+
+// 文件类型
+export const FILE_TYPES = {
+ IMAGE: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'],
+ DOCUMENT: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
+ ARCHIVE: ['zip', 'rar', '7z', 'tar', 'gz'],
+ CODE: ['js', 'ts', 'vue', 'html', 'css', 'scss', 'json', 'xml', 'py', 'java', 'cpp', 'c'],
+ VIDEO: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'],
+ AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg']
+}
+
+// 文件大小限制(字节)
+export const FILE_SIZE_LIMITS = {
+ IMAGE: 10 * 1024 * 1024, // 10MB
+ DOCUMENT: 50 * 1024 * 1024, // 50MB
+ ARCHIVE: 100 * 1024 * 1024, // 100MB
+ CODE: 5 * 1024 * 1024, // 5MB
+ VIDEO: 500 * 1024 * 1024, // 500MB
+ AUDIO: 100 * 1024 * 1024 // 100MB
+}
+
+// 分页配置
+export const PAGINATION_CONFIG = {
+ PAGE_SIZE: 20,
+ PAGE_SIZES: [10, 20, 50, 100],
+ LAYOUT: 'total, sizes, prev, pager, next, jumper'
+}
+
+// 表格配置
+export const TABLE_CONFIG = {
+ STRIPE: true,
+ BORDER: true,
+ SIZE: 'default',
+ HIGHLIGHT_CURRENT_ROW: true,
+ EMPTY_TEXT: '暂无数据'
+}
+
+// 消息配置
+export const MESSAGE_CONFIG = {
+ DURATION: 3000,
+ SHOW_CLOSE: true,
+ CENTER: false
+}
+
+// 通知配置
+export const NOTIFICATION_CONFIG = {
+ DURATION: 4500,
+ POSITION: 'top-right'
+}
+
+// 加载配置
+export const LOADING_CONFIG = {
+ TEXT: '加载中...',
+ SPINNER: 'el-icon-loading',
+ BACKGROUND: 'rgba(0, 0, 0, 0.7)'
+}
+
+// 对话框配置
+export const DIALOG_CONFIG = {
+ WIDTH: '50%',
+ TOP: '15vh',
+ MODAL: true,
+ MODAL_APPEND_TO_BODY: true,
+ APPEND_TO_BODY: false,
+ LOCK_SCROLL: true,
+ CUSTOM_CLASS: '',
+ CLOSE_ON_CLICK_MODAL: true,
+ CLOSE_ON_PRESS_ESCAPE: true,
+ SHOW_CLOSE: true
+}
+
+// 抽屉配置
+export const DRAWER_CONFIG = {
+ SIZE: '30%',
+ DIRECTION: 'rtl',
+ MODAL: true,
+ MODAL_APPEND_TO_BODY: true,
+ APPEND_TO_BODY: false,
+ LOCK_SCROLL: true,
+ CLOSE_ON_PRESS_ESCAPE: true,
+ SHOW_CLOSE: true
+}
+
+// 表单验证规则
+export const FORM_RULES = {
+ REQUIRED: { required: true, message: '此项为必填项', trigger: 'blur' },
+ EMAIL: { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
+ URL: { type: 'url', message: '请输入正确的URL地址', trigger: 'blur' },
+ NUMBER: { type: 'number', message: '请输入数字', trigger: 'blur' },
+ INTEGER: { type: 'integer', message: '请输入整数', trigger: 'blur' },
+ PHONE: { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
+ PASSWORD: { min: 6, max: 20, message: '密码长度为6-20位', trigger: 'blur' }
+}
+
+// 正则表达式
+export const REGEX_PATTERNS = {
+ EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+ PHONE: /^1[3-9]\d{9}$/,
+ ID_CARD: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/,
+ URL: /^https?:\/\/(([a-zA-Z0-9_-])+(\.)?)*(:\d+)?(\/((\.)?(\?)?=?&?[a-zA-Z0-9_-](\?)?)*)*$/i,
+ IP: /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
+ USERNAME: /^[a-zA-Z_][a-zA-Z0-9_]{3,19}$/,
+ CHINESE_NAME: /^[\u4e00-\u9fa5]{2,10}$/,
+ PASSWORD: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/
+}
+
+// 错误码映射
+export const ERROR_CODE_MAP = {
+ 400: '请求参数错误',
+ 401: '未授权访问',
+ 403: '禁止访问',
+ 404: '资源不存在',
+ 405: '请求方法不允许',
+ 408: '请求超时',
+ 409: '资源冲突',
+ 422: '请求参数验证失败',
+ 429: '请求过于频繁',
+ 500: '服务器内部错误',
+ 502: '网关错误',
+ 503: '服务不可用',
+ 504: '网关超时'
+}
+
+// 成功码映射
+export const SUCCESS_CODE_MAP = {
+ 200: '请求成功',
+ 201: '创建成功',
+ 202: '请求已接受',
+ 204: '删除成功'
+}
+
+// 默认头像
+export const DEFAULT_AVATAR = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMjAiIGZpbGw9IiNGNUY1RjUiLz4KPHBhdGggZD0iTTIwIDIwQzIzLjMxMzcgMjAgMjYgMTcuMzEzNyAyNiAxNEMyNiAxMC42ODYzIDIzLjMxMzcgOCAyMCA4QzE2LjY4NjMgOCAxNCA0LjY4NjMgMTQgMTRDMTQgMTcuMzEzNyAxNi42ODYzIDIwIDIwIDIwWiIgZmlsbD0iI0NDQ0NDQyIvPgo8cGF0aCBkPSJNMjAgMjJDMTQuNDc3MiAyMiAxMCAyNi40NzcyIDEwIDMyVjM0QzEwIDM1LjEwNDYgMTAuODk1NCAzNiAxMiAzNkgyOEMyOS4xMDQ2IDM2IDMwIDM1LjEwNDYgMzAgMzRWMzJDMzAgMjYuNDc3MiAyNS41MjI4IDIyIDIwIDIyWiIgZmlsbD0iI0NDQ0NDQyIvPgo8L3N2Zz4K'
+
+// 系统信息
+export const SYSTEM_INFO = {
+ NAME: 'MCPStore',
+ VERSION: import.meta.env.VITE_APP_VERSION || '1.4.1',
+ DESCRIPTION: import.meta.env.VITE_APP_DESCRIPTION || 'MCP工具服务商店',
+ AUTHOR: 'MCPStore Team',
+ COPYRIGHT: `© ${new Date().getFullYear()} MCPStore. All rights reserved.`
+}
diff --git a/vue/src/utils/env.js b/vue/src/utils/env.js
new file mode 100644
index 00000000..b3f6e532
--- /dev/null
+++ b/vue/src/utils/env.js
@@ -0,0 +1,18 @@
+export const getRequiredEnv = (name) => {
+ const value = import.meta.env[name]
+ if (value === undefined || value === null || value === '') {
+ throw new Error(`[Config] Missing required environment variable: ${name}`)
+ }
+ return value
+}
+
+export const getRequiredEnvNumber = (name) => {
+ const raw = getRequiredEnv(name)
+ const num = Number(raw)
+ if (!Number.isFinite(num)) {
+ throw new Error(`[Config] Environment variable ${name} must be a finite number`)
+ }
+ return num
+}
+
+
diff --git a/vue/src/utils/format.js b/vue/src/utils/format.js
new file mode 100644
index 00000000..f93d7a70
--- /dev/null
+++ b/vue/src/utils/format.js
@@ -0,0 +1,247 @@
+/**
+ * 格式化工具函数
+ */
+
+/**
+ * 格式化日期时间
+ * @param {Date|string|number} date 日期
+ * @param {string} format 格式化字符串
+ * @returns {string} 格式化后的日期字符串
+ */
+export function formatDateTime(date, format = 'YYYY-MM-DD HH:mm:ss') {
+ if (!date) return ''
+
+ const d = new Date(date)
+ if (isNaN(d.getTime())) return ''
+
+ const year = d.getFullYear()
+ const month = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ const hours = String(d.getHours()).padStart(2, '0')
+ const minutes = String(d.getMinutes()).padStart(2, '0')
+ const seconds = String(d.getSeconds()).padStart(2, '0')
+
+ return format
+ .replace('YYYY', year)
+ .replace('MM', month)
+ .replace('DD', day)
+ .replace('HH', hours)
+ .replace('mm', minutes)
+ .replace('ss', seconds)
+}
+
+/**
+ * 格式化相对时间
+ * @param {Date|string|number} date 日期
+ * @returns {string} 相对时间字符串
+ */
+export function formatRelativeTime(date) {
+ if (!date) return ''
+
+ const d = new Date(date)
+ if (isNaN(d.getTime())) return ''
+
+ const now = new Date()
+ const diff = now.getTime() - d.getTime()
+ const seconds = Math.floor(diff / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+
+ if (seconds < 60) return '刚刚'
+ if (minutes < 60) return `${minutes}分钟前`
+ if (hours < 24) return `${hours}小时前`
+ if (days < 7) return `${days}天前`
+
+ return formatDateTime(date, 'YYYY-MM-DD')
+}
+
+/**
+ * 格式化文件大小
+ * @param {number} bytes 字节数
+ * @param {number} decimals 小数位数
+ * @returns {string} 格式化后的文件大小
+ */
+export function formatFileSize(bytes, decimals = 2) {
+ if (bytes === 0) return '0 B'
+
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+}
+
+/**
+ * 格式化数字
+ * @param {number} num 数字
+ * @param {number} decimals 小数位数
+ * @returns {string} 格式化后的数字
+ */
+export function formatNumber(num, decimals = 0) {
+ if (isNaN(num)) return '0'
+
+ return Number(num).toLocaleString('zh-CN', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals
+ })
+}
+
+/**
+ * 格式化百分比
+ * @param {number} num 数字
+ * @param {number} decimals 小数位数
+ * @returns {string} 格式化后的百分比
+ */
+export function formatPercentage(num, decimals = 1) {
+ if (isNaN(num)) return '0%'
+
+ return (num * 100).toFixed(decimals) + '%'
+}
+
+/**
+ * 格式化货币
+ * @param {number} amount 金额
+ * @param {string} currency 货币符号
+ * @returns {string} 格式化后的货币
+ */
+export function formatCurrency(amount, currency = '¥') {
+ if (isNaN(amount)) return currency + '0.00'
+
+ return currency + Number(amount).toLocaleString('zh-CN', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ })
+}
+
+/**
+ * 格式化手机号
+ * @param {string} phone 手机号
+ * @returns {string} 格式化后的手机号
+ */
+export function formatPhone(phone) {
+ if (!phone) return ''
+
+ const cleaned = phone.replace(/\D/g, '')
+ if (cleaned.length === 11) {
+ return cleaned.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')
+ }
+
+ return phone
+}
+
+/**
+ * 格式化身份证号
+ * @param {string} idCard 身份证号
+ * @param {boolean} mask 是否遮罩
+ * @returns {string} 格式化后的身份证号
+ */
+export function formatIdCard(idCard, mask = true) {
+ if (!idCard) return ''
+
+ if (mask && idCard.length === 18) {
+ return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
+ }
+
+ return idCard
+}
+
+/**
+ * 格式化银行卡号
+ * @param {string} cardNumber 银行卡号
+ * @param {boolean} mask 是否遮罩
+ * @returns {string} 格式化后的银行卡号
+ */
+export function formatBankCard(cardNumber, mask = true) {
+ if (!cardNumber) return ''
+
+ const cleaned = cardNumber.replace(/\D/g, '')
+
+ if (mask && cleaned.length >= 8) {
+ const start = cleaned.slice(0, 4)
+ const end = cleaned.slice(-4)
+ const middle = '*'.repeat(cleaned.length - 8)
+ return `${start} ${middle} ${end}`.replace(/(.{4})/g, '$1 ').trim()
+ }
+
+ return cleaned.replace(/(.{4})/g, '$1 ').trim()
+}
+
+/**
+ * 格式化JSON
+ * @param {any} obj 对象
+ * @param {number} space 缩进空格数
+ * @returns {string} 格式化后的JSON字符串
+ */
+export function formatJSON(obj, space = 2) {
+ try {
+ return JSON.stringify(obj, null, space)
+ } catch (error) {
+ return String(obj)
+ }
+}
+
+/**
+ * 格式化URL
+ * @param {string} url URL
+ * @returns {string} 格式化后的URL
+ */
+export function formatURL(url) {
+ if (!url) return ''
+
+ if (!/^https?:\/\//i.test(url)) {
+ return 'http://' + url
+ }
+
+ return url
+}
+
+/**
+ * 格式化状态文本
+ * @param {string|number} status 状态值
+ * @param {object} statusMap 状态映射
+ * @returns {string} 状态文本
+ */
+export function formatStatus(status, statusMap = {}) {
+ return statusMap[status] || status || '未知'
+}
+
+/**
+ * 格式化枚举值
+ * @param {string|number} value 枚举值
+ * @param {Array} enumList 枚举列表
+ * @returns {string} 枚举文本
+ */
+export function formatEnum(value, enumList = []) {
+ const item = enumList.find(item => item.value === value)
+ return item ? item.label : value || '未知'
+}
+
+/**
+ * 截断文本
+ * @param {string} text 文本
+ * @param {number} length 最大长度
+ * @param {string} suffix 后缀
+ * @returns {string} 截断后的文本
+ */
+export function truncateText(text, length = 50, suffix = '...') {
+ if (!text || text.length <= length) return text || ''
+
+ return text.slice(0, length) + suffix
+}
+
+/**
+ * 高亮关键词
+ * @param {string} text 文本
+ * @param {string} keyword 关键词
+ * @param {string} className CSS类名
+ * @returns {string} 高亮后的HTML
+ */
+export function highlightKeyword(text, keyword, className = 'highlight') {
+ if (!text || !keyword) return text || ''
+
+ const regex = new RegExp(`(${keyword})`, 'gi')
+ return text.replace(regex, `$1 `)
+}
diff --git a/vue/src/utils/index.js b/vue/src/utils/index.js
new file mode 100644
index 00000000..c10d6804
--- /dev/null
+++ b/vue/src/utils/index.js
@@ -0,0 +1,212 @@
+/**
+ * 工具函数入口文件
+ */
+
+export * from './format'
+export * from './validate'
+export * from './constants'
+
+/**
+ * 深拷贝对象
+ * 递归复制对象的所有属性,支持 Date、Array 和普通对象
+ *
+ * @param {any} obj - 要拷贝的对象
+ * @returns {any} 拷贝后的对象
+ *
+ * @example
+ * const original = { a: 1, b: { c: 2 } }
+ * const cloned = deepClone(original)
+ * cloned.b.c = 3
+ * console.log(original.b.c) // 输出: 2
+ */
+export function deepClone(obj) {
+ if (obj === null || typeof obj !== 'object') return obj
+ if (obj instanceof Date) return new Date(obj.getTime())
+ if (obj instanceof Array) return obj.map(item => deepClone(item))
+ if (typeof obj === 'object') {
+ const clonedObj = {}
+ for (const key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ clonedObj[key] = deepClone(obj[key])
+ }
+ }
+ return clonedObj
+ }
+}
+
+/**
+ * 防抖函数
+ * 在事件触发 n 秒后才执行,如果在 n 秒内又触发了事件,则重新计时
+ *
+ * @param {Function} func - 要防抖的函数
+ * @param {number} wait - 等待时间(毫秒)
+ * @returns {Function} 防抖后的函数
+ *
+ * @example
+ * const debouncedSearch = debounce((query) => {
+ * console.log('Searching for:', query)
+ * }, 300)
+ *
+ * // 多次快速调用只会执行最后一次
+ * debouncedSearch('a')
+ * debouncedSearch('ab')
+ * debouncedSearch('abc') // 只有这次会在 300ms 后执行
+ */
+export function debounce(func, wait) {
+ let timeout
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout)
+ func(...args)
+ }
+ clearTimeout(timeout)
+ timeout = setTimeout(later, wait)
+ }
+}
+
+/**
+ * 节流函数
+ * 限制函数在指定时间内只能执行一次
+ *
+ * @param {Function} func - 要节流的函数
+ * @param {number} limit - 时间限制(毫秒)
+ * @returns {Function} 节流后的函数
+ *
+ * @example
+ * const throttledScroll = throttle(() => {
+ * console.log('Scroll event handled')
+ * }, 1000)
+ *
+ * window.addEventListener('scroll', throttledScroll)
+ * // 无论滚动多快,每秒最多执行一次
+ */
+export function throttle(func, limit) {
+ let inThrottle
+ return function executedFunction(...args) {
+ if (!inThrottle) {
+ func.apply(this, args)
+ inThrottle = true
+ setTimeout(() => inThrottle = false, limit)
+ }
+ }
+}
+
+/**
+ * 生成UUID
+ * @returns {string} UUID字符串
+ */
+export function generateUUID() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = Math.random() * 16 | 0
+ const v = c === 'x' ? r : (r & 0x3 | 0x8)
+ return v.toString(16)
+ })
+}
+
+/**
+ * 获取文件扩展名
+ * @param {string} filename 文件名
+ * @returns {string} 扩展名
+ */
+export function getFileExtension(filename) {
+ return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
+}
+
+/**
+ * 下载文件
+ * @param {string} url 文件URL
+ * @param {string} filename 文件名
+ */
+export function downloadFile(url, filename) {
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+}
+
+/**
+ * 复制文本到剪贴板
+ * 优先使用现代 Clipboard API,如果不支持则降级使用 execCommand
+ *
+ * @param {string} text - 要复制的文本
+ * @returns {Promise} 是否成功复制
+ *
+ * @example
+ * const success = await copyToClipboard('Hello World')
+ * if (success) {
+ * console.log('复制成功')
+ * } else {
+ * console.log('复制失败')
+ * }
+ */
+export async function copyToClipboard(text) {
+ try {
+ await navigator.clipboard.writeText(text)
+ return true
+ } catch (err) {
+ // 降级方案
+ const textArea = document.createElement('textarea')
+ textArea.value = text
+ document.body.appendChild(textArea)
+ textArea.select()
+ try {
+ document.execCommand('copy')
+ return true
+ } catch (err) {
+ return false
+ } finally {
+ document.body.removeChild(textArea)
+ }
+ }
+}
+
+/**
+ * 获取浏览器信息
+ * @returns {object} 浏览器信息
+ */
+export function getBrowserInfo() {
+ const ua = navigator.userAgent
+ const isChrome = /Chrome/.test(ua) && /Google Inc/.test(navigator.vendor)
+ const isFirefox = /Firefox/.test(ua)
+ const isSafari = /Safari/.test(ua) && /Apple Computer/.test(navigator.vendor)
+ const isEdge = /Edg/.test(ua)
+
+ return {
+ isChrome,
+ isFirefox,
+ isSafari,
+ isEdge,
+ userAgent: ua
+ }
+}
+
+/**
+ * 检查是否为移动设备
+ * @returns {boolean} 是否为移动设备
+ */
+export function isMobile() {
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+}
+
+/**
+ * 获取URL参数
+ * @param {string} name 参数名
+ * @returns {string|null} 参数值
+ */
+export function getUrlParam(name) {
+ const urlParams = new URLSearchParams(window.location.search)
+ return urlParams.get(name)
+}
+
+/**
+ * 设置URL参数
+ * @param {string} name 参数名
+ * @param {string} value 参数值
+ */
+export function setUrlParam(name, value) {
+ const url = new URL(window.location)
+ url.searchParams.set(name, value)
+ window.history.pushState({}, '', url)
+}
diff --git a/vue/src/utils/logger.js b/vue/src/utils/logger.js
new file mode 100644
index 00000000..2e4dd3bc
--- /dev/null
+++ b/vue/src/utils/logger.js
@@ -0,0 +1,19 @@
+export const logger = {
+ isEnabled() {
+ // 仅在开发环境或明确开启时输出调试信息
+ return import.meta.env.DEV || import.meta.env.VITE_ENABLE_CONSOLE_LOG === 'true'
+ },
+ debug(...args) {
+ if (this.isEnabled()) console.log(...args)
+ },
+ info(...args) {
+ if (this.isEnabled()) console.log(...args)
+ },
+ warn(...args) {
+ if (this.isEnabled()) console.warn(...args)
+ },
+ error(...args) {
+ // 错误永远打印
+ console.error(...args)
+ }
+}
diff --git a/vue/src/utils/schema.js b/vue/src/utils/schema.js
new file mode 100644
index 00000000..f7a1621a
--- /dev/null
+++ b/vue/src/utils/schema.js
@@ -0,0 +1,77 @@
+/**
+ * JSON Schema 工具函数
+ * 用于处理工具的输入参数 schema
+ */
+
+/**
+ * 将 JSON Schema 转换为简洁的文本摘要
+ * @param {Object} schema - JSON Schema 对象
+ * @returns {string} - 参数摘要文本
+ */
+export function summarizeInputs(schema) {
+ if (!schema || typeof schema !== 'object') return ''
+ if (schema.type !== 'object' || !schema.properties) return ''
+
+ const required = Array.isArray(schema.required) ? schema.required : []
+ const props = schema.properties || {}
+ const parts = []
+
+ for (const key of Object.keys(props)) {
+ const p = props[key] || {}
+ const isReq = required.includes(key)
+ const type = p.type || 'any'
+ const extras = []
+
+ if (p.default !== undefined) extras.push(`default:${String(p.default)}`)
+ if (typeof p.minimum === 'number') extras.push(`min:${p.minimum}`)
+ if (typeof p.maximum === 'number') extras.push(`max:${p.maximum}`)
+
+ const extraText = extras.length ? ` (${extras.join(', ')})` : ''
+ parts.push(`${key}${isReq ? '*' : ''}: ${type}${extraText}`)
+ }
+
+ return parts.join(', ')
+}
+
+/**
+ * 将 JSON Schema 转换为结构化列表
+ * @param {Object} schema - JSON Schema 对象
+ * @returns {Array} - 参数列表数组
+ */
+export function schemaToList(schema) {
+ const list = []
+
+ if (!schema || schema.type !== 'object' || !schema.properties) return list
+
+ const required = Array.isArray(schema.required) ? schema.required : []
+ const props = schema.properties || {}
+
+ for (const key of Object.keys(props)) {
+ const p = props[key] || {}
+ const type = p.type || 'any'
+ const extras = []
+
+ if (p.default !== undefined) extras.push(`default:${String(p.default)}`)
+ if (typeof p.minimum === 'number') extras.push(`min:${p.minimum}`)
+ if (typeof p.maximum === 'number') extras.push(`max:${p.maximum}`)
+
+ list.push({
+ key,
+ required: required.includes(key),
+ type,
+ extras: extras.join(', ')
+ })
+ }
+
+ return list
+}
+
+/**
+ * 获取参数数量
+ * @param {Object} schema - JSON Schema 对象
+ * @returns {number} - 参数数量
+ */
+export function getParameterCount(schema) {
+ if (!schema || !schema.properties) return 0
+ return Object.keys(schema.properties).length
+}
diff --git a/vue/src/utils/validate.js b/vue/src/utils/validate.js
new file mode 100644
index 00000000..be98bb5a
--- /dev/null
+++ b/vue/src/utils/validate.js
@@ -0,0 +1,299 @@
+/**
+ * 验证工具函数
+ */
+
+/**
+ * 验证邮箱
+ * @param {string} email 邮箱地址
+ * @returns {boolean} 是否有效
+ */
+export function validateEmail(email) {
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return regex.test(email)
+}
+
+/**
+ * 验证手机号
+ * @param {string} phone 手机号
+ * @returns {boolean} 是否有效
+ */
+export function validatePhone(phone) {
+ const regex = /^1[3-9]\d{9}$/
+ return regex.test(phone)
+}
+
+/**
+ * 验证身份证号
+ * @param {string} idCard 身份证号
+ * @returns {boolean} 是否有效
+ */
+export function validateIdCard(idCard) {
+ if (!idCard || idCard.length !== 18) return false
+
+ const regex = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
+ if (!regex.test(idCard)) return false
+
+ // 验证校验码
+ const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
+ const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
+
+ let sum = 0
+ for (let i = 0; i < 17; i++) {
+ sum += parseInt(idCard[i]) * weights[i]
+ }
+
+ const checkCode = checkCodes[sum % 11]
+ return checkCode === idCard[17].toUpperCase()
+}
+
+/**
+ * 验证URL
+ * @param {string} url URL地址
+ * @returns {boolean} 是否有效
+ */
+export function validateURL(url) {
+ try {
+ new URL(url)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * 验证IP地址
+ * @param {string} ip IP地址
+ * @returns {boolean} 是否有效
+ */
+export function validateIP(ip) {
+ const regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
+ return regex.test(ip)
+}
+
+/**
+ * 验证端口号
+ * @param {string|number} port 端口号
+ * @returns {boolean} 是否有效
+ */
+export function validatePort(port) {
+ const num = parseInt(port)
+ return !isNaN(num) && num >= 1 && num <= 65535
+}
+
+/**
+ * 验证密码强度
+ * @param {string} password 密码
+ * @returns {object} 验证结果
+ */
+export function validatePassword(password) {
+ if (!password) {
+ return { valid: false, strength: 0, message: '密码不能为空' }
+ }
+
+ let strength = 0
+ const checks = {
+ length: password.length >= 8,
+ lowercase: /[a-z]/.test(password),
+ uppercase: /[A-Z]/.test(password),
+ number: /\d/.test(password),
+ special: /[!@#$%^&*(),.?":{}|<>]/.test(password)
+ }
+
+ strength += checks.length ? 1 : 0
+ strength += checks.lowercase ? 1 : 0
+ strength += checks.uppercase ? 1 : 0
+ strength += checks.number ? 1 : 0
+ strength += checks.special ? 1 : 0
+
+ let message = ''
+ if (strength < 3) {
+ message = '密码强度较弱'
+ } else if (strength < 4) {
+ message = '密码强度中等'
+ } else {
+ message = '密码强度较强'
+ }
+
+ return {
+ valid: strength >= 3,
+ strength,
+ message,
+ checks
+ }
+}
+
+/**
+ * 验证用户名
+ * @param {string} username 用户名
+ * @returns {boolean} 是否有效
+ */
+export function validateUsername(username) {
+ if (!username) return false
+
+ // 4-20位,字母、数字、下划线,不能以数字开头
+ const regex = /^[a-zA-Z_][a-zA-Z0-9_]{3,19}$/
+ return regex.test(username)
+}
+
+/**
+ * 验证中文姓名
+ * @param {string} name 姓名
+ * @returns {boolean} 是否有效
+ */
+export function validateChineseName(name) {
+ if (!name) return false
+
+ const regex = /^[\u4e00-\u9fa5]{2,10}$/
+ return regex.test(name)
+}
+
+/**
+ * 验证银行卡号
+ * @param {string} cardNumber 银行卡号
+ * @returns {boolean} 是否有效
+ */
+export function validateBankCard(cardNumber) {
+ if (!cardNumber) return false
+
+ const cleaned = cardNumber.replace(/\D/g, '')
+ if (cleaned.length < 16 || cleaned.length > 19) return false
+
+ // Luhn算法验证
+ let sum = 0
+ let isEven = false
+
+ for (let i = cleaned.length - 1; i >= 0; i--) {
+ let digit = parseInt(cleaned[i])
+
+ if (isEven) {
+ digit *= 2
+ if (digit > 9) {
+ digit -= 9
+ }
+ }
+
+ sum += digit
+ isEven = !isEven
+ }
+
+ return sum % 10 === 0
+}
+
+/**
+ * 验证JSON格式
+ * @param {string} jsonString JSON字符串
+ * @returns {boolean} 是否有效
+ */
+export function validateJSON(jsonString) {
+ try {
+ JSON.parse(jsonString)
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * 验证正整数
+ * @param {string|number} value 值
+ * @returns {boolean} 是否有效
+ */
+export function validatePositiveInteger(value) {
+ const num = parseInt(value)
+ return !isNaN(num) && num > 0 && num.toString() === value.toString()
+}
+
+/**
+ * 验证非负数
+ * @param {string|number} value 值
+ * @returns {boolean} 是否有效
+ */
+export function validateNonNegativeNumber(value) {
+ const num = parseFloat(value)
+ return !isNaN(num) && num >= 0
+}
+
+/**
+ * 验证数字范围
+ * @param {string|number} value 值
+ * @param {number} min 最小值
+ * @param {number} max 最大值
+ * @returns {boolean} 是否有效
+ */
+export function validateNumberRange(value, min, max) {
+ const num = parseFloat(value)
+ return !isNaN(num) && num >= min && num <= max
+}
+
+/**
+ * 验证字符串长度
+ * @param {string} str 字符串
+ * @param {number} min 最小长度
+ * @param {number} max 最大长度
+ * @returns {boolean} 是否有效
+ */
+export function validateStringLength(str, min = 0, max = Infinity) {
+ if (typeof str !== 'string') return false
+ return str.length >= min && str.length <= max
+}
+
+/**
+ * 验证文件类型
+ * @param {File} file 文件对象
+ * @param {Array} allowedTypes 允许的类型
+ * @returns {boolean} 是否有效
+ */
+export function validateFileType(file, allowedTypes = []) {
+ if (!file || !allowedTypes.length) return false
+
+ return allowedTypes.some(type => {
+ if (type.startsWith('.')) {
+ return file.name.toLowerCase().endsWith(type.toLowerCase())
+ } else {
+ return file.type.toLowerCase().includes(type.toLowerCase())
+ }
+ })
+}
+
+/**
+ * 验证文件大小
+ * @param {File} file 文件对象
+ * @param {number} maxSize 最大大小(字节)
+ * @returns {boolean} 是否有效
+ */
+export function validateFileSize(file, maxSize) {
+ if (!file) return false
+ return file.size <= maxSize
+}
+
+/**
+ * 验证日期格式
+ * @param {string} dateString 日期字符串
+ * @param {string} format 日期格式
+ * @returns {boolean} 是否有效
+ */
+export function validateDateFormat(dateString, format = 'YYYY-MM-DD') {
+ if (!dateString) return false
+
+ const date = new Date(dateString)
+ return !isNaN(date.getTime())
+}
+
+/**
+ * 验证日期范围
+ * @param {string|Date} date 日期
+ * @param {string|Date} minDate 最小日期
+ * @param {string|Date} maxDate 最大日期
+ * @returns {boolean} 是否有效
+ */
+export function validateDateRange(date, minDate, maxDate) {
+ const d = new Date(date)
+ const min = new Date(minDate)
+ const max = new Date(maxDate)
+
+ if (isNaN(d.getTime())) return false
+ if (minDate && !isNaN(min.getTime()) && d < min) return false
+ if (maxDate && !isNaN(max.getTime()) && d > max) return false
+
+ return true
+}
diff --git a/vue/src/views/ApiDebugPage.vue b/vue/src/views/ApiDebugPage.vue
new file mode 100644
index 00000000..f7fe885b
--- /dev/null
+++ b/vue/src/views/ApiDebugPage.vue
@@ -0,0 +1,277 @@
+
+
+
🔍 API调试页面
+
+
+
+ API测试
+
+
+
+
+ 测试Services API
+
+
+ 测试Tools API
+
+
+ 测试Health API
+
+
+ 清除结果
+
+
+
+
+
+
+ 测试结果
+
+
+
+
{{ result.title }}
+
+ {{ result.success ? '✅ 成功' : '❌ 失败' }}
+
+
+
原始响应:
+
{{ JSON.stringify(result.response, null, 2) }}
+
+
数据类型分析:
+
+ 响应类型: {{ result.analysis.responseType }}
+ 是否有data字段: {{ result.analysis.hasData }}
+ data类型: {{ result.analysis.dataType }}
+ data是否为数组: {{ result.analysis.isDataArray }}
+ 响应是否为数组: {{ result.analysis.isResponseArray }}
+ 数据长度: {{ result.analysis.dataLength }}
+
+
+
建议处理方式:
+
{{ result.analysis.suggestion }}
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/CacheSpace.vue b/vue/src/views/CacheSpace.vue
new file mode 100644
index 00000000..96dd59e7
--- /dev/null
+++ b/vue/src/views/CacheSpace.vue
@@ -0,0 +1,738 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ key.key }}
+
+ {{ key.category }}
+ {{ key.originalType }}
+
+
+
+
+
+ No keys found.
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a key to inspect value.
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/Dashboard.vue b/vue/src/views/Dashboard.vue
new file mode 100644
index 00000000..be720a0e
--- /dev/null
+++ b/vue/src/views/Dashboard.vue
@@ -0,0 +1,612 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+ {{ row.type || 'remote' }}
+
+
+
+
+
+
+
+
+ {{ row.status }}
+
+
+
+
+
+
+ {{ row.tools_count || 0 }}
+
+
+
+
+
+ {{ formatLastChange(row.name) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+
+
+
+
+ {{ row.service || '-' }}
+
+
+
+
+ {{ row.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Environment
+ Production
+
+
+ API Version
+ v0.6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/ExternalEmbed.vue b/vue/src/views/ExternalEmbed.vue
new file mode 100644
index 00000000..59622a45
--- /dev/null
+++ b/vue/src/views/ExternalEmbed.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/ExternalLink.vue b/vue/src/views/ExternalLink.vue
new file mode 100644
index 00000000..662b6a9f
--- /dev/null
+++ b/vue/src/views/ExternalLink.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+ {{ description }}
+
+
+
+
+
+ {{ url }}
+
+
+
+
+
+
+
+
+ Open in New Tab
+
+
+
+
+
+ Copy Link
+
+
+
+
+
+
+ This external site cannot be embedded due to security policies.
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/NotFound.vue b/vue/src/views/NotFound.vue
new file mode 100644
index 00000000..945240c1
--- /dev/null
+++ b/vue/src/views/NotFound.vue
@@ -0,0 +1,78 @@
+
+
+
+
+ 404
+
+
+ 页面未找到
+
+
+ 抱歉,您访问的页面不存在或已被移除
+
+
+
+ 返回首页
+
+
+ 返回上页
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/ServiceMonitoring.vue b/vue/src/views/ServiceMonitoring.vue
new file mode 100644
index 00000000..e69de29b
diff --git a/vue/src/views/TestPage.vue b/vue/src/views/TestPage.vue
new file mode 100644
index 00000000..cd0c03eb
--- /dev/null
+++ b/vue/src/views/TestPage.vue
@@ -0,0 +1,190 @@
+
+
+
🧪 环境测试页面
+
+
+
+ 环境信息
+
+
+
+
当前模式: {{ currentMode }}
+
API地址: {{ apiBaseUrl }}
+
Base URL: {{ baseUrl }}
+
当前路径: {{ currentPath }}
+
完整URL: {{ fullUrl }}
+
+
+
+
+
+ API连接测试
+
+
+
+
+ 测试API连接
+
+
+ 测试服务列表
+
+
+
+
+
测试结果:
+
{{ testResult }}
+
+
+
+
+
+ 路由测试
+
+
+
+
+ 跳转到仪表板
+
+
+ 跳转到服务列表
+
+
+ 跳转到工具列表
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/WorkspaceManager.vue b/vue/src/views/WorkspaceManager.vue
new file mode 100644
index 00000000..0625a1a3
--- /dev/null
+++ b/vue/src/views/WorkspaceManager.vue
@@ -0,0 +1,849 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ workspaceStats.total }}
+
+
+ 总工作空间
+
+
+
+
+
+
+
+
+
+ {{ workspaceStats.active }}
+
+
+ 当前活跃
+
+
+
+
+
+
+
+
+
+ {{ workspaceStats.totalServices }}
+
+
+ 服务总数
+
+
+
+
+
+
+
+
+
+ {{ workspaceStats.totalTools }}
+
+
+ 工具总数
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 服务数量
+ {{ workspace.service_count || 0 }}
+
+
+ 创建时间
+ {{ formatDate(workspace.created_at) }}
+
+
+ 最后更新
+ {{ formatDate(workspace.updated_at) }}
+
+
+
+
+
+
+ 切换
+
+
+ 编辑
+
+
+ 删除
+
+
+
+
+
+
+
+
暂无工作空间
+
创建您的第一个工作空间来开始管理服务
+
+ 创建工作空间
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ {{ editingWorkspace ? '保存' : '创建' }}
+
+
+
+
+
+
+
+
+
+
+
确定要删除工作空间 "{{ deletingWorkspace?.name }}" 吗?
+
+ 此操作不可撤销,所有相关数据将被永久删除。
+
+
+
+
+ 取消
+
+
+ 删除
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/agents/AgentDetail.vue b/vue/src/views/agents/AgentDetail.vue
new file mode 100644
index 00000000..adc16cfe
--- /dev/null
+++ b/vue/src/views/agents/AgentDetail.vue
@@ -0,0 +1,815 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ row.name }}
+
+ {{ row.command ? 'LOCAL' : 'REMOTE' }}
+ CONFIG ONLY
+
+
+
+
+
+
+
+
+
+ {{ row.url }}
+
+ $ {{ row.command }}
+
+ {{ row.transport || (row.command ? 'stdio' : 'http') }}
+
+
+
+
+
+
+ {{ row.tool_count || 0 }}
+
+
+
+
+
+
+ Edit
+ Restart
+ Del
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+
+
+
+
+
+
+ {{ row.service_name }}
+
+
+
+
+
+ {{ row.description || '-' }}
+
+
+
+
+
+ Run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Agent ID
+ {{ agentId }}
+
+
+ Status
+
+ {{ isHealthy ? 'Healthy' : 'Issues' }}
+
+
+
+ Orchestrator
+ {{ agentStats.orchestrator_status || 'Active' }}
+
+
+
+
+
+
+
+
+
+
+ Refresh Context
+
+
+
+ View Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/agents/AgentList.vue b/vue/src/views/agents/AgentList.vue
new file mode 100644
index 00000000..dc734cb7
--- /dev/null
+++ b/vue/src/views/agents/AgentList.vue
@@ -0,0 +1,406 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
No Agents Found
+
Create your first service to initialize an agent context.
+
+ Add Service
+
+
+
+
+
+
+
+
+
+
+ {{ agent.description || 'No description provided.' }}
+
+
+
+
+ {{ agent.services || 0 }}
+ Services
+
+
+ {{ agent.tools || 0 }}
+ Tools
+
+
+ {{ agent.healthy_services || 0 }}
+ Healthy
+
+
+ {{ agent.unhealthy_services || 0 }}
+ Issues
+
+
+
+
+ Last Active: {{ formatTime(agent.last_activity) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/agents/ServiceAdd.vue b/vue/src/views/agents/ServiceAdd.vue
new file mode 100644
index 00000000..1ec2bec0
--- /dev/null
+++ b/vue/src/views/agents/ServiceAdd.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/config/ConfigCenter.vue b/vue/src/views/config/ConfigCenter.vue
new file mode 100644
index 00000000..823deb08
--- /dev/null
+++ b/vue/src/views/config/ConfigCenter.vue
@@ -0,0 +1,715 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ file.name }}
+ {{ file.type.toUpperCase() }}
+
+
+
+
+
+
+
+
+
+
+
+ Total Services
+ {{ serviceCount }}
+
+
+
+
+ {{ svc.name }}
+
+ +{{ servicesList.length - 10 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a configuration file to edit.
+
+
+
+
+
+
+
+
+ Importing will overwrite your current configuration. Only valid JSON files are accepted.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ URL: {{ svc.url }}
+
+
+ CMD: {{ svc.command }} {{ (svc.args||[]).join(' ') }}
+
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/config/ConfigEditor.vue b/vue/src/views/config/ConfigEditor.vue
new file mode 100644
index 00000000..a06bd8cf
--- /dev/null
+++ b/vue/src/views/config/ConfigEditor.vue
@@ -0,0 +1,1157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ file.name }}
+
+
+ {{ file.path }}
+
+
+
+ {{ file.type.toUpperCase() }}
+
+ {{ formatFileSize(file.size) }}
+
+
+
+
+ handleFileAction(cmd, file)"
+ >
+
+
+
+
+
+
+ 编辑
+
+
+ 复制
+
+
+ 导出
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 添加属性
+
+
+ 添加对象
+
+
+ 添加数组
+
+
+
+ 重置
+
+
+ 格式化
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择配置文件
+
从左侧列表中选择一个配置文件进行编辑
+
+
+
+
+
+
+
+
+
+
+
MCP Server 配置架构
+
+
+ object - MCP服务器配置对象
+
+
+ object - 服务器配置
+
+
command : string - 启动命令
+
args : string[] - 命令参数
+
env : object - 环境变量
+
cwd : string - 工作目录
+
+
+
+
+
+
+
+
数据空间配置架构
+
+
+ object - 数据空间配置对象
+
+
+ object - 空间配置
+
+
path : string - 存储路径
+
maxSize : string - 最大大小
+
retention : string - 保留策略
+
+
+
+
+
+
+
+
+ 关闭
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ 导入
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/config/McpConfigManager.vue b/vue/src/views/config/McpConfigManager.vue
new file mode 100644
index 00000000..90411a40
--- /dev/null
+++ b/vue/src/views/config/McpConfigManager.vue
@@ -0,0 +1,648 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 文件信息
+
+
+
+
+
+ 文件状态:
+
+ {{ isModified ? '已修改' : '已保存' }}
+
+
+
+
+ JSON格式:
+
+ {{ hasErrors ? '格式错误' : '格式正确' }}
+
+
+
+
+ 服务数量:
+ {{ serviceCount }} 个
+
+
+
+ 最后更新:
+ {{ lastUpdateTime || '未知' }}
+
+
+
+
+
+
+
+
+
+ 服务预览 ({{ serviceCount }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 命令:
+ {{ service.command }}
+
+
+
+ URL:
+ {{ service.url }}
+
+
+
+ 参数:
+ {{ service.args.join(' ') }}
+
+
+
+
+
+
+
+
+
+
+
+ 快速操作
+
+
+
+
+
+ 添加示例服务
+
+
+
+ 清空配置
+
+
+
+ 重置为默认
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/services/BatchUpdateDialog.vue b/vue/src/views/services/BatchUpdateDialog.vue
new file mode 100644
index 00000000..7d46c07a
--- /dev/null
+++ b/vue/src/views/services/BatchUpdateDialog.vue
@@ -0,0 +1,444 @@
+
+
+
+
+
+
选中的服务 ({{ services.length }}个)
+
+
+ {{ service.name }}
+
+
+
+
+
+
+
更新选项
+
+
+
+
+
+ 更新传输类型
+
+
+
+
+
+
+
+
+
+
+
+ 更新超时时间
+
+
+ 秒
+
+
+
+
+
+ 更新保持连接
+
+
+
+
+
+
+
+ 更新请求头
+
+
+
+
+
+
+
+ 更新环境变量
+
+
+
+
+
+
+
+
+
更新预览
+
+
+
+
+
+
+ 取消
+
+
+ 批量更新
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/services/ServiceAdd.vue b/vue/src/views/services/ServiceAdd.vue
new file mode 100644
index 00000000..f8892523
--- /dev/null
+++ b/vue/src/views/services/ServiceAdd.vue
@@ -0,0 +1,831 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Remote (HTTP/SSE)
+
+
+
+ Local (Stdio)
+
+
+
+ Config (JSON)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Service URL *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Working Directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+ JSON Configuration *
+
+
+
+
+ Format
+
+
+ Load Example
+
+
+
+
+
+
+
+ Register Service
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NAME
+ {{ previewService.name || '-' }}
+
+
+ TYPE
+ {{ previewService.type }}
+
+
+
+
+ URL
+ {{ previewService.url || '-' }}
+
+
+ TRANSPORT
+ {{ previewService.transport }}
+
+
+
+
+
+ COMMAND
+ $ {{ previewService.command }}
+
+
+ ARGS
+ {{ previewService.args.join(' ') }}
+
+
+
+
+
+
+
GENERATED CONFIG
+
{{ configPreview }}
+
+
+
+
+ Fill out the form to see preview.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/services/ServiceDetail.vue b/vue/src/views/services/ServiceDetail.vue
new file mode 100644
index 00000000..e9f1b757
--- /dev/null
+++ b/vue/src/views/services/ServiceDetail.vue
@@ -0,0 +1,665 @@
+
+
+
+
+
+
+
+
+
+
+ Loading service details...
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ {{ serviceData.name }}
+
+
+ Type
+ {{ serviceData.command ? 'Local (Stdio)' : 'Remote' }}
+
+
+ Client ID
+ {{ serviceData.client_id || '-' }}
+
+
+ Transport
+ {{ serviceData.transport || 'HTTP' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ URL
+ {{ serviceData.url }}
+
+
+
+
+ Command
+ $ {{ serviceData.command }}
+
+
+ Arguments
+ {{ serviceData.args.join(' ') }}
+
+
+ CWD
+ {{ serviceData.working_dir }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Restart
+
+
+ Disconnect
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+ No tools available.
+
+
+
+
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/services/ServiceEdit.vue b/vue/src/views/services/ServiceEdit.vue
new file mode 100644
index 00000000..8db84b0d
--- /dev/null
+++ b/vue/src/views/services/ServiceEdit.vue
@@ -0,0 +1,760 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 高级配置
+
+
+
+
+ 连接超时时间(秒)
+
+
+
+
+ 是否保持长连接
+
+
+
+
+
+
+ 取消
+
+
+ {{ isEdit ? '更新服务' : '添加服务' }}
+
+
+ 增量更新
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/src/views/services/ServiceList.vue b/vue/src/views/services/ServiceList.vue
new file mode 100644
index 00000000..61b80530
--- /dev/null
+++ b/vue/src/views/services/ServiceList.vue
@@ -0,0 +1,1047 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+ {{ row.type }}
+
+
+
+
+
+
+
+
+
+ {{ row.url }}
+
+
+
+ $ {{ row.command }}
+
+
+
+
+
+
+
+
+
+ {{ row.tools_count || 0 }}
+
+
+
+
+
+
+
+ {{ row.status }}
+
+
+
+
+
+
+
+
+ Detail
+
+
+ Config
+
+
+ {{ row.restarting ? '...' : 'Restart' }}
+
+
+
+
+
+
+
+ No services found
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fields
+ JSON
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/tools/ToolExecute.vue b/vue/src/views/tools/ToolExecute.vue
new file mode 100644
index 00000000..9851d747
--- /dev/null
+++ b/vue/src/views/tools/ToolExecute.vue
@@ -0,0 +1,1016 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/tools/ToolList.vue b/vue/src/views/tools/ToolList.vue
new file mode 100644
index 00000000..d5e0f3bb
--- /dev/null
+++ b/vue/src/views/tools/ToolList.vue
@@ -0,0 +1,611 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/src/views/tools/ToolRecords.vue b/vue/src/views/tools/ToolRecords.vue
new file mode 100644
index 00000000..e87775bf
--- /dev/null
+++ b/vue/src/views/tools/ToolRecords.vue
@@ -0,0 +1,527 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vue/vite.config.js b/vue/vite.config.js
new file mode 100644
index 00000000..9c39b265
--- /dev/null
+++ b/vue/vite.config.js
@@ -0,0 +1,90 @@
+import { defineConfig, loadEnv } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+
+ // 环境变量获取(带默认值)
+ const getEnv = (name, defaultValue) => env[name] || defaultValue
+ const port = Number(getEnv('VITE_DEV_PORT', 5177))
+ const host = getEnv('VITE_DEV_HOST', 'localhost')
+
+ return {
+ plugins: [
+ vue(),
+ AutoImport({
+ resolvers: [ElementPlusResolver()],
+ imports: ['vue', 'vue-router', 'pinia'],
+ dts: true
+ }),
+ Components({
+ resolvers: [ElementPlusResolver()],
+ dts: true
+ })
+ ],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ '@components': resolve(__dirname, 'src/components'),
+ '@views': resolve(__dirname, 'src/views'),
+ '@utils': resolve(__dirname, 'src/utils'),
+ '@api': resolve(__dirname, 'src/api'),
+ '@stores': resolve(__dirname, 'src/stores'),
+ '@assets': resolve(__dirname, 'src/assets')
+ }
+ },
+ base: '/',
+ server: {
+ port,
+ host, // 由环境变量控制
+ open: false,
+ cors: true,
+ allowedHosts: [
+ 'web.mcpstore.wiki',
+ '.mcpstore.wiki' // 允许所有 mcpstore.wiki 的子域名
+ ],
+ hmr: {
+ port,
+ host: 'localhost'
+ }
+ },
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: mode !== 'production',
+ minify: 'terser',
+ chunkSizeWarningLimit: 1000,
+ rollupOptions: {
+ output: {
+ chunkFileNames: 'js/[name]-[hash].js',
+ entryFileNames: 'js/[name]-[hash].js',
+ assetFileNames: 'assets/[name]-[hash].[ext]',
+ manualChunks: {
+ 'element-plus': ['element-plus'],
+ 'vue-vendor': ['vue', 'vue-router', 'pinia']
+ }
+ }
+ }
+ },
+ preview: {
+ port,
+ host: '0.0.0.0'
+ },
+ define: {
+ global: 'globalThis',
+ globalThis: 'globalThis'
+ },
+ css: {
+ preprocessorOptions: {
+ scss: {
+ additionalData: '@use "@/styles/variables.scss" as *;'
+ }
+ }
+ }
+ }
+})
diff --git a/wiki/mcp_service_wiki.py b/wiki/mcp_service_wiki.py
new file mode 100644
index 00000000..bd943e41
--- /dev/null
+++ b/wiki/mcp_service_wiki.py
@@ -0,0 +1,256 @@
+from fastmcp import FastMCP
+from fastmcp.server.dependencies import get_http_request, get_http_headers
+from pydantic import Field
+from typing import Annotated
+import random
+import logging
+import sys
+import threading
+import time
+import glob
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+import json
+
+# Log persistence configuration
+LOG_DIR = Path("logs")
+LOG_DIR.mkdir(exist_ok=True)
+MAX_LOG_SIZE = 5 * 1024 * 1024 # 5MB = 5 * 1024 * 1024 bytes
+LOG_CLEANUP_DAYS = 30 # Keep logs for 30 days
+LOG_CLEANUP_INTERVAL = 86400 # Check cleanup once per day = 86400 seconds
+
+class SizeBasedRotatingLogHandler:
+ """Size-based log rotation handler"""
+
+ def __init__(self, log_dir: Path, max_size: int, cleanup_days: int, cleanup_interval: int):
+ self.log_dir = log_dir
+ self.max_size = max_size
+ self.cleanup_days = cleanup_days
+ self.cleanup_interval = cleanup_interval
+ self.current_handler = None
+ self.current_log_file = None
+ self.log_counter = 1
+ self.last_cleanup = time.time()
+ self.lock = threading.Lock()
+
+ # Create initial log file
+ self._rotate_log()
+
+ # Start cleanup thread
+ self.cleanup_thread = threading.Thread(target=self._cleanup_worker, daemon=True)
+ self.cleanup_thread.start()
+
+ def _get_log_filename(self):
+ """Generate log filename with date and sequence number"""
+ date_str = datetime.now().strftime("%Y%m%d")
+ return self.log_dir / f"mcpstorewiki_{date_str}_{self.log_counter:03d}.log"
+
+ def _get_current_log_size(self):
+ """Get current log file size"""
+ if self.current_log_file and self.current_log_file.exists():
+ return self.current_log_file.stat().st_size
+ return 0
+
+ def _should_rotate(self):
+ """Check if log rotation is needed"""
+ return self._get_current_log_size() >= self.max_size
+
+ def _rotate_log(self):
+ """Rotate log file"""
+ with self.lock:
+ # Close current handler
+ if self.current_handler:
+ logger.removeHandler(self.current_handler)
+ self.current_handler.close()
+
+ # Create new file handler
+ self.current_log_file = self._get_log_filename()
+ self.current_handler = logging.FileHandler(self.current_log_file, encoding='utf-8')
+ self.current_handler.setFormatter(
+ logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+ )
+ logger.addHandler(self.current_handler)
+
+ logger.info(f"📁 Log rotation: New log file {self.current_log_file} (max size: {self.max_size / 1024 / 1024:.1f}MB)")
+ self.log_counter += 1
+
+ def check_and_rotate(self):
+ """Check file size and rotate if needed"""
+ if self._should_rotate():
+ self._rotate_log()
+
+ def _cleanup_old_logs(self):
+ """Clean up old log files"""
+ cutoff_time = datetime.now() - timedelta(days=self.cleanup_days)
+ pattern = str(self.log_dir / "mcpstorewiki_*.log")
+
+ cleaned_count = 0
+ for log_file in glob.glob(pattern):
+ file_path = Path(log_file)
+ if file_path.stat().st_mtime < cutoff_time.timestamp():
+ try:
+ file_path.unlink()
+ cleaned_count += 1
+ logger.info(f"🗑️ Cleaned old log: {file_path}")
+ except Exception as e:
+ logger.error(f"❌ Failed to clean log {file_path}: {e}")
+
+ if cleaned_count > 0:
+ logger.info(f"🧹 Log cleanup completed: Deleted {cleaned_count} old log files")
+ else:
+ logger.info("🧹 Log cleanup completed: No files to clean")
+
+ def _cleanup_worker(self):
+ """Log cleanup worker thread"""
+ while True:
+ time.sleep(self.cleanup_interval)
+ self._cleanup_old_logs()
+
+# Configure basic logging
+logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.StreamHandler(sys.stdout)
+ ]
+)
+
+logger = logging.getLogger(__name__)
+
+# Start log persistence
+log_handler = SizeBasedRotatingLogHandler(LOG_DIR, MAX_LOG_SIZE, LOG_CLEANUP_DAYS, LOG_CLEANUP_INTERVAL)
+
+# Create FastMCP instance
+mcp = FastMCP(
+ name="WeatherService"
+)
+
+# Enhanced request logging function
+def log_request_info(endpoint_name, **kwargs):
+ """Log key information: time, client IP, input parameters (concise logging)"""
+ request_time = datetime.now()
+
+ # Get client IP
+ client_ip = "unknown"
+ try:
+ request = get_http_request()
+ if request and request.client:
+ client_ip = request.client.host
+
+ # Try to get real IP from headers (handle proxy cases)
+ headers = get_http_headers()
+ if headers:
+ # Check common proxy headers
+ for header_name in ['X-Forwarded-For', 'X-Real-IP', 'CF-Connecting-IP']:
+ if header_name in headers:
+ forwarded_ip = headers[header_name].split(',')[0].strip()
+ if forwarded_ip:
+ client_ip = forwarded_ip
+ break
+ except Exception as e:
+ logger.debug(f"Failed to get client IP: {e}")
+
+ # Concise request start log
+ logger.info(f"request_start endpoint={endpoint_name} time={request_time.isoformat()} ip={client_ip}")
+
+ # Brief parameter recording (avoid too long)
+ if kwargs:
+ safe_kwargs = {}
+ for key, value in kwargs.items():
+ if isinstance(value, str) and len(value) > 200:
+ safe_kwargs[key] = value[:200] + "..."
+ else:
+ safe_kwargs[key] = value
+ logger.info(f"request_params endpoint={endpoint_name} params={safe_kwargs}")
+
+ # Record HTTP request details (if available)
+ try:
+ request = get_http_request()
+ if request:
+ logger.info(f"request_http endpoint={endpoint_name} path={request.url.path} method={request.method}")
+
+ headers = get_http_headers()
+ if headers:
+ header_summary = {}
+ for header_name in ['User-Agent', 'Content-Type', 'X-Forwarded-For']:
+ if header_name in headers:
+ header_summary[header_name] = headers[header_name]
+ if header_summary:
+ logger.info(f"request_headers endpoint={endpoint_name} headers={header_summary}")
+ except Exception as e:
+ logger.debug(f"Failed to get HTTP request details: {e}")
+
+ # Check log file size and rotate if needed
+ log_handler.check_and_rotate()
+
+ return {
+ 'timestamp': request_time.isoformat(),
+ 'client_ip': client_ip,
+ 'endpoint': endpoint_name,
+ 'params': kwargs
+ }
+
+@mcp.tool()
+def get_current_weather(
+ query: Annotated[str, Field(description="City name to query weather, e.g., Beijing, Shanghai, Guangzhou")]
+) -> str:
+ """Get current weather information for specified city, including temperature and weather conditions"""
+ # Log detailed request information
+ request_info = log_request_info("get_current_weather", query=query)
+
+ weather_conditions = ["Sunny", "Cloudy", "Light Rain", "Overcast", "Snow"]
+ temperature = random.randint(-5, 35)
+ condition = random.choice(weather_conditions)
+ humidity = random.randint(30, 90)
+
+ result = f"{query} current weather: {condition}, temperature {temperature}°C, humidity {humidity}%"
+
+ # Brief completion log
+ end_time = datetime.now()
+ logger.info(f"tool_done name=get_current_weather time={end_time.isoformat()} ip={request_info['client_ip']} result={result}")
+
+ # Check log file size and rotate if needed
+ log_handler.check_and_rotate()
+
+ return result
+
+
+@mcp.tool()
+def get_mcpstore_docs() -> str:
+ """Return mcpstore documentation URL"""
+ # Log detailed request information
+ request_info = log_request_info("get_mcpstore_docs")
+
+ result = "https://doc.mcpstore.wiki/"
+
+ # Brief completion log
+ end_time = datetime.now()
+ logger.info(f"tool_done name=get_mcpstore_docs time={end_time.isoformat()} ip={request_info['client_ip']} url={result}")
+
+ # Check log file size and rotate if needed
+ log_handler.check_and_rotate()
+
+ return result
+
+
+if __name__ == "__main__":
+ logger.info(f"service_start transport=streamable-http host=0.0.0.0 port=21923 path=/mcp")
+ logger.info(
+ f"log_config dir={LOG_DIR.absolute()} max_mb={MAX_LOG_SIZE / 1024 / 1024:.1f}"
+ f" cleanup_days={LOG_CLEANUP_DAYS} cleanup_interval_sec={LOG_CLEANUP_INTERVAL}"
+ )
+
+ try:
+ logger.info("service_boot")
+ mcp.run(
+ transport="streamable-http",
+ host="0.0.0.0",
+ port=21923,
+ path="/mcp"
+ )
+ except Exception as e:
+ logger.error(f"service_start_failed error={e} type={type(e).__name__}")
+ import traceback
+ logger.error(f"trace={traceback.format_exc()}")
+ raise
diff --git a/wiki/mcp_service_wiki_studio.py b/wiki/mcp_service_wiki_studio.py
new file mode 100644
index 00000000..5d703c89
--- /dev/null
+++ b/wiki/mcp_service_wiki_studio.py
@@ -0,0 +1,162 @@
+"""
+MCP Store Wiki - Local Studio Version (STDIO Mode)
+用于 Claude Desktop / Claude Code 等本地 MCP 客户端
+
+This version runs in STDIO mode for local development and testing.
+"""
+from fastmcp import FastMCP
+from pydantic import Field
+from typing import Annotated
+import random
+import logging
+import sys
+from datetime import datetime
+from pathlib import Path
+
+# Configure logging for stdio mode
+LOG_DIR = Path("logs")
+LOG_DIR.mkdir(exist_ok=True)
+
+# Setup logging - for stdio mode, we log to file to avoid interfering with stdio communication
+log_file = LOG_DIR / f"mcpstore_studio_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler(log_file, encoding='utf-8')
+ ]
+)
+
+logger = logging.getLogger(__name__)
+
+# Create FastMCP instance
+mcp = FastMCP(
+ name="MCPStoreWiki",
+ instructions="MCP Store Wiki service providing documentation and weather tools"
+)
+
+@mcp.tool()
+def get_current_weather(
+ query: Annotated[str, Field(description="城市名称,例如:北京、上海、广州")]
+) -> str:
+ """获取指定城市的当前天气信息,包括温度和天气状况"""
+ logger.info(f"获取天气信息请求: city={query}")
+
+ weather_conditions = ["晴天", "多云", "小雨", "阴天", "雪"]
+ temperature = random.randint(-5, 35)
+ condition = random.choice(weather_conditions)
+ humidity = random.randint(30, 90)
+
+ result = f"{query}当前天气:{condition},温度{temperature}°C,湿度{humidity}%"
+ logger.info(f"天气信息返回: {result}")
+
+ return result
+
+
+@mcp.tool()
+def get_mcpstore_docs() -> str:
+ """获取 MCPStore 的文档链接"""
+ logger.info("获取文档链接请求")
+ result = "MCPStore 文档地址:https://doc.mcpstore.wiki/"
+ logger.info(f"文档链接返回: {result}")
+ return result
+
+
+@mcp.tool()
+def get_weather_forecast(
+ city: Annotated[str, Field(description="城市名称")],
+ days: Annotated[int, Field(description="预报天数 (1-7)", ge=1, le=7)] = 3
+) -> str:
+ """获取未来几天的天气预报"""
+ logger.info(f"获取天气预报请求: city={city}, days={days}")
+
+ weather_conditions = ["晴天", "多云", "小雨", "阴天", "雪"]
+ forecast = [f"{city}未来{days}天天气预报:\n"]
+
+ for day in range(1, days + 1):
+ temperature = random.randint(-5, 35)
+ condition = random.choice(weather_conditions)
+ forecast.append(f"第{day}天:{condition},温度{temperature}°C")
+
+ result = "\n".join(forecast)
+ logger.info(f"天气预报返回: {result}")
+
+ return result
+
+
+@mcp.resource("mcpstore://wiki/introduction")
+def get_introduction() -> str:
+ """MCPStore 项目简介"""
+ return """
+# MCPStore 项目简介
+
+MCPStore 是一个强大的 Model Context Protocol (MCP) 服务管理平台。
+
+## 核心特性
+- 🚀 快速部署 MCP 服务
+- 🔧 灵活的工具管理
+- 📊 实时健康监控
+- 🔄 双向同步机制
+- 💾 Redis 缓存支持
+
+## 文档资源
+- 官方文档:https://doc.mcpstore.wiki/
+- GitHub:https://github.com/MCPStore/mcpstore
+ """
+
+
+@mcp.resource("mcpstore://wiki/quickstart")
+def get_quickstart() -> str:
+ """MCPStore 快速开始指南"""
+ return """
+# MCPStore 快速开始
+
+## 安装
+```bash
+pip install mcpstore
+```
+
+## 基本使用
+```python
+from mcpstore import MCPHub
+
+# 创建 MCP Hub 实例
+hub = MCPHub()
+
+# 启动服务
+hub.start()
+```
+
+## 更多信息
+访问 https://doc.mcpstore.wiki/ 获取完整文档
+ """
+
+
+@mcp.prompt()
+def explain_mcp(
+ topic: Annotated[str, Field(description="需要解释的 MCP 主题")] = "overview"
+) -> str:
+ """生成关于 MCP 概念的解释提示词"""
+ prompts = {
+ "overview": "请解释什么是 Model Context Protocol (MCP),它的核心价值是什么?",
+ "tools": "请详细说明 MCP 中的工具(tools)概念,以及如何使用它们?",
+ "resources": "请解释 MCP 中的资源(resources)是什么,它们与工具有什么区别?",
+ "prompts": "请说明 MCP 中的提示词(prompts)功能,以及它们的使用场景?"
+ }
+
+ return prompts.get(topic, f"请解释 MCP 中关于 {topic} 的概念")
+
+
+if __name__ == "__main__":
+ logger.info("=" * 60)
+ logger.info("MCPStore Wiki Studio - 启动 (STDIO 模式)")
+ logger.info(f"日志文件: {log_file.absolute()}")
+ logger.info("=" * 60)
+
+ try:
+ # Run in STDIO mode for Claude Desktop integration
+ logger.info("服务启动: transport=stdio")
+ mcp.run(transport="stdio")
+ except Exception as e:
+ logger.error(f"服务启动失败: {e}", exc_info=True)
+ raise
diff --git "a/\346\261\202\346\261\202\345\220\204\344\275\215\344\275\254\346\217\220\347\202\271bug\345\220\247QAQ" "b/\346\261\202\346\261\202\345\220\204\344\275\215\344\275\254\346\217\220\347\202\271bug\345\220\247QAQ"
new file mode 100644
index 00000000..2e128a3f
--- /dev/null
+++ "b/\346\261\202\346\261\202\345\220\204\344\275\215\344\275\254\346\217\220\347\202\271bug\345\220\247QAQ"
@@ -0,0 +1,49 @@
+断开删除服务的区别
+服务状态确认
+数据一致性确认
+redis协作确认
+数据空间接口
+
+设计测试 包括 mcp.json有配置/无配置/无json 有数据空间/无数据空间 有db/无db :每个情况都要添加服务 修改服务 连接服务等等 设计统一的测试方法 测试pyvk中的键值是否正常 写完善的版本
+
+
+参考claude 的cli添加服务的方式
+添加sqllite
+有导出文件》?
+unified sync manager 会从 ~/.mcpstore/mcp.json 读取已有服务,批量调用
+ add_service_async(store 视角)把它们写入缓存/注册。???这里检查 数据空间是否生效
+
+docker
+setup
+vue2
+tool代理
+redis和缓存都做了什么
+redis添加传递示例的方法
+hub
+认证
+doc首页添加langchain的使用记录
+分析session的调用实质
+agent的调用的是本地的call方法吗
+还有哪些没有封装fastmcp客户端
+
+agent的id是否是唯一的 是否是唯一哈希
+
+vue的数据返回格式
+vue精简无用的组件
+
+
+vue的配置 各个env bulid的作用 各个文件的作用 ts区别 npmn的区别
+添加动图 完善redeam
+
+重置服务问题
+为什么有的waring的模式
+
+wait服务的时候是否应该保持不让他失败 持续尝试等待
+就是如果我的服务响应的慢 哪怕我wait也不会让他成功吗?
+
+findservice 和 listservice的数据不一致
+
+
+清空服务的 时候我的 工具快照没有标注脏数据 重置服务 更新服务 重置json 清空缓存 这些时候是否都应该
+
+/for_agent/{agent_id}/summary 文档待删除