feat(§03): 实现 MCP Server 核心 - Storage/Service/Tool/MCP Protocol Layers

**Storage Layer:**
- Database 类(aiosqlite + WAL + 并发保护)
- WikiPage 数据模型
- fix_dirty_states 恢复机制
- FTS5 全文搜索支持
- 完整的 SQLite 表结构

**Service Layer:**
- CacheService(LRU 缓存 + TTL + 大小限制)
- QueryService(查询服务)
- ParserService(Markdown 解析)
- IndexerService(索引服务)
- GraphService(链接图服务)

**Tool Layer (8 个 MCP Tools):**
- wiki_query - FTS5 全文搜索 + 标签过滤
- memory_bridge - 按工具来源浏览
- wiki_status - 索引状态
- wiki_lint - 健康审计
- cross_linker - 缺失链接发现
- tag_taxonomy - 标签一致性
- wiki_synthesize - 跨概念综合分析
- daily_update - 日常维护 + hot.md 生成

**MCP Protocol Layer:**
- MCP 协议解析和封装
- 工具注册和路由
- 错误处理和日志
- stdio 模式支持

**配置和部署:**
- requirements.txt
- config.example.yaml
- ecosystem.config.cjs (PM2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:32:43 +08:00
parent 67a53d82de
commit dfd8421dc6
22 changed files with 2561 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
"""
Tool Layer - 工具层
提供所有 MCP Tools。
"""
from .wiki_query import WikiQueryTool
from .memory_bridge import MemoryBridgeTool
from .wiki_status import WikiStatusTool
from .wiki_lint import WikiLintTool
from .cross_linker import CrossLinkerTool
from .tag_taxonomy import TagTaxonomyTool
from .wiki_synthesize import WikiSynthesizeTool
from .daily_update import DailyUpdateTool
__all__ = [
"WikiQueryTool",
"MemoryBridgeTool",
"WikiStatusTool",
"WikiLintTool",
"CrossLinkerTool",
"TagTaxonomyTool",
"WikiSynthesizeTool",
"DailyUpdateTool"
]
+87
View File
@@ -0,0 +1,87 @@
"""
Tool Layer - cross_linker 工具
扫描 wiki,自动发现缺失的交叉引用。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any, List
from ..services import GraphService
logger = logging.getLogger(__name__)
class CrossLinkerTool:
"""cross_linker 工具实现"""
def __init__(self, graph_service: GraphService):
self.graph_service = graph_service
async def handle(self, path: str = "", dry_run: bool = True) -> Dict[str, Any]:
"""
扫描 wiki,发现缺失的交叉引用
Args:
path: 指定路径(空字符串表示全部)
dry_run: 是否为模拟运行(不实际修改)
Returns:
{
"missing_links": [
{
"source": "practices/a.md",
"target": "concepts/x",
"suggestion": "Consider creating [[concepts/x]]"
}
],
"total": 12,
"dry_run": true
}
"""
# 获取缺失的链接
all_missing = await self.graph_service.find_missing_links()
# 过滤指定路径
if path:
missing_links = [(s, t) for s, t in all_missing if s == path or t == path]
else:
missing_links = all_missing
# 格式化结果
missing_links_formatted = []
for source, target in missing_links:
missing_links_formatted.append({
"source": source,
"target": target,
"suggestion": f"Consider creating [[{target}]] or updating the link in [[{source}]]"
})
return {
"missing_links": missing_links_formatted[:50], # 最多返回 50 条
"total": len(missing_links),
"dry_run": dry_run
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "cross_linker",
"description": "扫描 wiki,自动发现缺失的交叉引用",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"default": "",
"description": "指定路径(空字符串表示全部)"
},
"dry_run": {
"type": "boolean",
"default": True,
"description": "是否为模拟运行(不实际修改)"
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
"""
Tool Layer - daily_update 工具
日常维护(检查源新鲜度、更新 index、重新生成 hot.md)。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any
from datetime import datetime
from pathlib import Path
from ..services import QueryService, IndexerService
logger = logging.getLogger(__name__)
class DailyUpdateTool:
"""daily_update 工具实现"""
def __init__(self, query_service: QueryService, indexer_service: IndexerService):
self.query_service = query_service
self.indexer_service = indexer_service
async def handle(self) -> Dict[str, Any]:
"""
日常维护
Returns:
{
"updated": 5,
"new": 2,
"hot_md_generated": true,
"index_status": {...}
}
"""
# 1. 增量更新索引
index_status = await self.indexer_service.incremental_update()
# 2. 获取最近更新的页面
recent_pages = await self.query_service.db.get_recent_pages(days=7, limit=20)
# 3. 获取新增标签
new_tags = await self.query_service.db.get_new_tags(days=7)
# 4. 查找孤立页面
from ..services import GraphService
graph_service = GraphService(self.query_service.db)
orphans = await graph_service.find_orphans()
# 5. 生成 hot.md
hot_md_generated = await self._generate_hot_md(recent_pages, new_tags, orphans)
return {
"updated": len(recent_pages),
"new": len(new_tags),
"hot_md_generated": hot_md_generated,
"index_status": index_status
}
async def _generate_hot_md(self, recent_pages, new_tags, orphans) -> bool:
"""生成热点文件"""
try:
# 确定 hot.md 保存路径
vault_path = Path(self.query_service.db.path).parent.parent / "wiki-vault"
hot_path = vault_path / "hot.md"
# 格式化内容
content = f"""# Wiki Hot - {datetime.now().strftime('%Y-%m-%d')}
## 最近更新(7 天内)
"""
for page in recent_pages[:10]:
content += f"- [[{page.path}|{page.title}]] - {page.updated_at}\n"
content += "\n## 新增标签(7 天内)\n\n"
for tag, updated_at in new_tags[:10]:
content += f"- `{tag}` - {updated_at}\n"
content += "\n## 待链接页面(孤立页面)\n\n"
for orphan in sorted(list(orphans))[:10]:
content += f"- [[{orphan}]]\n"
# 写入文件
with open(hot_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"Generated hot.md: {hot_path}")
return True
except Exception as e:
logger.error(f"Failed to generate hot.md: {e}")
return False
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "daily_update",
"description": "日常维护(检查源新鲜度、更新 index、重新生成 hot.md",
"inputSchema": {
"type": "object",
"properties": {}
}
}
+112
View File
@@ -0,0 +1,112 @@
"""
Tool Layer - memory_bridge 工具
按 AI 工具来源浏览和对比 wiki 知识。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from ..services import QueryService
logger = logging.getLogger(__name__)
class MemoryBridgeTool:
"""memory_bridge 工具实现"""
def __init__(self, query_service: QueryService):
self.query_service = query_service
async def handle(self, tool_name: str = "claude", date_range: str = "") -> Dict[str, Any]:
"""
按 AI 工具来源浏览和对比 wiki 知识
Args:
tool_name: AI 工具名称(claude/web_reader/gitea/other
date_range: 日期范围(如 "2024-01-01:2024-12-31" 或空字符串表示不限)
Returns:
{
"entries": [
{
"path": "practices/moziplus-orchestration.md",
"title": "moziplus 编排实践",
"summary": "...",
"updated_at": "2024-06-15T10:30:00",
"relevance_score": 0.85
}
],
"total": 12,
"tool_name": "claude",
"date_range": "2024-01-01:2024-12-31"
}
"""
# 按工具来源搜索
pages = await self.query_service.search_by_source_tool(tool_name, limit=100)
# 按日期范围过滤
if date_range:
try:
start_str, end_str = date_range.split(":")
start_date = datetime.fromisoformat(start_str)
end_date = datetime.fromisoformat(end_str)
filtered = []
for page in pages:
updated = datetime.fromisoformat(page.updated_at)
if start_date <= updated <= end_date:
filtered.append(page)
pages = filtered
except Exception as e:
logger.warning(f"Invalid date_range format '{date_range}': {e}")
# 格式化结果
entries = []
for page in pages:
# 计算相关性分数(基于摘要长度和更新时间)
recency_days = (datetime.now() - datetime.fromisoformat(page.updated_at)).days
relevance_score = max(0.1, 1.0 - recency_days / 365) # 简单衰减
entries.append({
"path": page.path,
"title": page.title,
"summary": page.summary,
"updated_at": page.updated_at,
"relevance_score": round(relevance_score, 2)
})
# 按相关性排序
entries.sort(key=lambda x: x["relevance_score"], reverse=True)
return {
"entries": entries[:50], # 最多返回 50 条
"total": len(entries),
"tool_name": tool_name,
"date_range": date_range
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "memory_bridge",
"description": "按 AI 工具来源浏览和对比 wiki 知识",
"inputSchema": {
"type": "object",
"properties": {
"tool_name": {
"type": "string",
"default": "claude",
"description": "AI 工具名称(claude/web_reader/gitea/other",
"enum": ["claude", "web_reader", "gitea", "other"]
},
"date_range": {
"type": "string",
"default": "",
"description": "日期范围,格式:YYYY-MM-DD:YYYY-MM-DD"
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
"""
Tool Layer - tag_taxonomy 工具
用受控词表强制标签一致性。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any, List, Set
from ..services import QueryService
logger = logging.getLogger(__name__)
class TagTaxonomyTool:
"""tag_taxonomy 工具实现"""
# 受控词表(可配置)
CONTROLLED_VOCABULARY: Set[str] = {
# 分类
"practices", "concepts", "entities", "projects", "skills", "references",
# 技术栈
"python", "typescript", "rust", "go", "swift",
# 领域
"ai", "ml", "database", "web", "mobile", "devops",
# 团队
"sanguo", "moziplus", "bitnet",
# 通用
"tutorial", "reference", "guide", "example", "draft"
}
def __init__(self, query_service: QueryService):
self.query_service = query_service
async def handle(self, path: str = "", enforce: bool = False) -> Dict[str, Any]:
"""
用受控词表强制标签一致性
Args:
path: 指定路径(空字符串表示全部)
enforce: 是否强制修正(否则仅报告)
Returns:
{
"conflicts": [
{
"path": "practices/example.md",
"invalid_tags": ["CustomTag"],
"suggested_tags": ["practices"]
}
],
"total_conflicts": 5,
"enforced": false
}
"""
conflicts = []
# 获取所有标签
all_tags = await self.query_service.db.get_all_tags()
# 检查非受控标签
invalid_tags = set(all_tags.keys()) - self.CONTROLLED_VOCABULARY
if invalid_tags:
# 获取使用这些标签的页面
for tag in invalid_tags:
tag_pages = await self.query_service.search_by_tags([tag])
for page in tag_pages:
page_tags = set(page.tags) & invalid_tags
if page_tags:
# 推断修正建议(基于页面分类)
suggested_tags = [page.category] if page.category else []
conflicts.append({
"path": page.path,
"invalid_tags": list(page_tags),
"suggested_tags": suggested_tags
})
if enforce and path and page.path == path:
# TODO: 实际修正标签
pass
return {
"conflicts": conflicts[:50],
"total_conflicts": len(conflicts),
"enforced": enforce
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "tag_taxonomy",
"description": "用受控词表强制标签一致性",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"default": "",
"description": "指定路径(空字符串表示全部)"
},
"enforce": {
"type": "boolean",
"default": False,
"description": "是否强制修正(否则仅报告)"
}
}
}
}
def get_controlled_vocabulary(self) -> List[str]:
"""获取当前受控词表"""
return sorted(list(self.CONTROLLED_VOCABULARY))
+164
View File
@@ -0,0 +1,164 @@
"""
Tool Layer - wiki_lint 工具
审计 wiki 健康(格式、链接、frontmatter 规范)。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any, List
from ..services import ParserService, QueryService, GraphService
logger = logging.getLogger(__name__)
class WikiLintTool:
"""wiki_lint 工具实现"""
def __init__(self, parser: ParserService, query_service: QueryService, graph_service: GraphService):
self.parser = parser
self.query_service = query_service
self.graph_service = graph_service
async def handle(self, path: str = "", level: str = "basic") -> Dict[str, Any]:
"""
审计 wiki 健康
Args:
path: 指定路径(空字符串表示全部)
level: 检查级别(basic/strict
Returns:
{
"issues": [
{
"path": "practices/example.md",
"type": "missing_title",
"message": "Missing title",
"severity": "warning"
}
],
"fixes": [
{
"path": "practices/example.md",
"action": "add_title",
"suggestion": "# Example Page"
}
],
"summary": {
"total_issues": 5,
"critical": 0,
"warning": 5,
"info": 0
}
}
"""
issues = []
fixes = []
# 获取所有页面或指定页面
if path:
pages = [await self.query_service.get_page(path)]
else:
pages = await self.query_service.db.get_all_pages()
summary = {"critical": 0, "warning": 0, "info": 0}
for page in pages:
# 读取内容
try:
from pathlib import Path
vault_path = Path(self.query_service.db.path).parent.parent / "wiki-vault"
full_path = vault_path / page.path
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
issues.append({
"path": page.path,
"type": "file_error",
"message": f"Failed to read file: {e}",
"severity": "critical"
})
summary["critical"] += 1
continue
# 验证页面
validation_issues = self.parser.validate_page(page.path, content)
for issue in validation_issues:
severity = "warning" if "Missing" in issue else "info"
issues.append({
"path": page.path,
"type": issue.lower().replace(" ", "_"),
"message": issue,
"severity": severity
})
summary[severity] += 1
# 生成修复建议
if "Missing title" in issue:
title = self.parser.extract_title(content)
if not title:
title = page.path.replace(".md", "").replace("-", " ").replace("_", " ").title()
fixes.append({
"path": page.path,
"action": "add_title",
"suggestion": f"# {title}"
})
# strict 模式额外检查
if level == "strict":
# 检查孤立页面
orphans = await self.graph_service.find_orphans()
for orphan in orphans:
issues.append({
"path": orphan,
"type": "orphan",
"message": "No backlinks found",
"severity": "info"
})
summary["info"] += 1
# 检查缺失链接
missing_links = await self.graph_service.find_missing_links()
for source, target in missing_links[:10]:
issues.append({
"path": source,
"type": "broken_link",
"message": f"Link to non-existent page: {target}",
"severity": "warning"
})
summary["warning"] += 1
return {
"issues": issues,
"fixes": fixes,
"summary": {
"total_issues": len(issues),
**summary
}
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "wiki_lint",
"description": "审计 wiki 健康(格式、链接、frontmatter 规范)",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"default": "",
"description": "指定路径(空字符串表示全部)"
},
"level": {
"type": "string",
"default": "basic",
"enum": ["basic", "strict"],
"description": "检查级别"
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
"""
Tool Layer - wiki_query 工具
基于 SQLite 索引查询 wiki,支持关键词(FTS5)和标签搜索。
参考设计文档:第 2.2 节
"""
import logging
from typing import List, Dict, Any
from ..services import QueryService
logger = logging.getLogger(__name__)
class WikiQueryTool:
"""wiki_query 工具实现"""
def __init__(self, query_service: QueryService):
self.query_service = query_service
async def handle(self, query: str = "", tags: List[str] = None, limit: int = 10) -> Dict[str, Any]:
"""
查询 wiki 页面
Args:
query: 查询关键词(FTS5 全文搜索)
tags: 标签过滤
limit: 返回数量限制
Returns:
{
"results": [
{
"path": "practices/moziplus-orchestration.md",
"title": "moziplus 编排实践",
"category": "practices",
"tags": ["moziplus", "orchestration"],
"summary": "...",
"lifecycle": "verified",
"updated_at": "2024-06-15T10:30:00",
"wikilink": "[[practices/moziplus-orchestration.md|moziplus 编排实践]]"
}
],
"total": 5,
"query": "...",
"tags": [...]
}
"""
if tags is None:
tags = []
results = []
# 组合查询
if query and tags:
# 先 FTS5 搜索,再标签过滤
fts_results = await self.query_service.search(query, limit * 2)
filtered = [r for r in fts_results if any(t in r.tags for t in tags)]
pages = filtered[:limit]
elif query:
# 仅 FTS5 搜索
pages = await self.query_service.search(query, limit)
elif tags:
# 仅标签搜索
pages = await self.query_service.search_by_tags(tags[:5]) # 限制标签数量
pages = pages[:limit]
else:
# 无查询条件,返回最近更新的页面
pages = []
# 格式化结果
for page in pages:
results.append({
"path": page.path,
"title": page.title,
"category": page.category,
"tags": page.tags,
"summary": page.summary,
"lifecycle": page.lifecycle,
"updated_at": page.updated_at,
"wikilink": f"[[{page.path}|{page.title}]]"
})
return {
"results": results,
"total": len(results),
"query": query,
"tags": tags
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "wiki_query",
"description": "查询 wiki 页面,支持关键词(FTS5)和标签搜索",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "查询关键词(FTS5 全文搜索)"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "标签过滤"
},
"limit": {
"type": "integer",
"default": 10,
"description": "返回数量限制"
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
"""
Tool Layer - wiki_status 工具
显示 wiki 当前状态(页面数、待处理项、增量差异)。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any
from ..services import QueryService, IndexerService, GraphService
logger = logging.getLogger(__name__)
class WikiStatusTool:
"""wiki_status 工具实现"""
def __init__(self, query_service: QueryService, indexer_service: IndexerService, graph_service: GraphService):
self.query_service = query_service
self.indexer_service = indexer_service
self.graph_service = graph_service
async def handle(self) -> Dict[str, Any]:
"""
显示 wiki 当前状态
Returns:
{
"stats": {
"total_pages": 1353,
"total_links": 4200,
"total_tags": 156,
"last_indexed": "2024-06-26T10:30:00"
},
"orphans": ["path1", "path2"],
"orphans_count": 5,
"dirty_pages": 12,
"cache_stats": {
"size": 100,
"max_size": 1000,
"utilization": 0.1
}
}
"""
# 获取统计信息
stats = await self.query_service.get_stats()
# 获取孤立页面
orphans = await self.graph_service.find_orphans()
# 获取索引状态
index_status = await self.indexer_service.get_index_status()
# 获取缓存统计
cache_stats = await self.query_service.cache.get_stats()
return {
"stats": stats,
"orphans": sorted(list(orphans)),
"orphans_count": len(orphans),
"dirty_pages": index_status.get("dirty_pages", 0),
"cache_stats": cache_stats
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "wiki_status",
"description": "显示 wiki 当前状态(页面数、待处理项、增量差异)",
"inputSchema": {
"type": "object",
"properties": {}
}
}
+105
View File
@@ -0,0 +1,105 @@
"""
Tool Layer - wiki_synthesize 工具
发现跨概念的综合分析机会,生成 synthesis 页面。
参考设计文档:第 2.2 节
"""
import logging
from typing import Dict, Any, List
from ..services import QueryService, GraphService
logger = logging.getLogger(__name__)
class WikiSynthesizeTool:
"""wiki_synthesize 工具实现"""
def __init__(self, query_service: QueryService, graph_service: GraphService):
self.query_service = query_service
self.graph_service = graph_service
async def handle(self, concepts: List[str] = None, threshold: float = 0.3) -> Dict[str, Any]:
"""
发现跨概念的综合分析机会
Args:
concepts: 概念列表(空表示自动发现)
threshold: 相关性阈值
Returns:
{
"synthesis": [
{
"concept_a": "SQLite",
"concept_b": "Concurrency",
"common_references": ["page1", "page2"],
"strength": 0.8,
"suggestion": "Consider creating synthesis page: SQLite & Concurrency"
}
],
"total": 5
}
"""
synthesis = []
# 如果未提供概念,自动发现高相关性概念
if not concepts:
# 获取高度连接的页面作为候选
highly_connected = await self.graph_service.get_highly_connected_pages(threshold=10)
concepts = [item["title"] for item in highly_connected[:10]]
# 分析概念间的关联
for i, concept_a in enumerate(concepts):
for concept_b in concepts[i + 1:]:
# 查找同时引用两个概念的页面
pages_a = await self.query_service.search(concept_a, limit=50)
pages_b = await self.query_service.search(concept_b, limit=50)
paths_a = {p.path for p in pages_a}
paths_b = {p.path for p in pages_b}
common = paths_a & paths_b
if len(common) >= 2: # 至少 2 个共同引用
strength = len(common) / max(len(paths_a), len(paths_b))
if strength >= threshold:
synthesis.append({
"concept_a": concept_a,
"concept_b": concept_b,
"common_references": sorted(list(common)),
"strength": round(strength, 2),
"suggestion": f"Consider creating synthesis page: {concept_a} & {concept_b}"
})
# 按关联强度排序
synthesis.sort(key=lambda x: x["strength"], reverse=True)
return {
"synthesis": synthesis[:20],
"total": len(synthesis)
}
def get_schema(self) -> dict:
"""返回 MCP Tool schema"""
return {
"name": "wiki_synthesize",
"description": "发现跨概念的综合分析机会,生成 synthesis 页面",
"inputSchema": {
"type": "object",
"properties": {
"concepts": {
"type": "array",
"items": {"type": "string"},
"description": "概念列表(空表示自动发现)"
},
"threshold": {
"type": "number",
"default": 0.3,
"description": "相关性阈值"
}
}
}
}