docs/design/2026-05-20-code-tools-design.md
日期:2026-05-20
范围:向 OpenViking 新增 code_outline、code_search、code_expand 三个 MCP 工具
三个新 MCP 工具通过 OpenViking 现有的 @mcp.tool() 端点向 AI Agent 暴露代码结构导航能力。典型使用顺序:
code_outline → 查看文件符号结构
code_search → 跨目录查找符号位置
code_expand → 展开完整实现代码
仅支持 viking:// URI(不支持直接本地路径),与现有 read、grep、list 等工具保持一致,符合服务端既有安全边界(参见 local_input_guard.py:HTTP MCP 端拒绝直接的本地文件系统路径)。需要分析本地代码时,先用 add_resource 入库为 viking:// 资源即可。
mcp_endpoint.py
├── code_outline(uri) → openviking.parse.parsers.code.ast
├── code_search(query, uri) → openviking.parse.parsers.code.ast
└── code_expand(uri, symbol) → openviking.parse.parsers.code.ast
openviking/parse/parsers/code/ast/
├── skeleton.py (修改:给 FunctionSig、ClassSkeleton 加行号字段)
├── extractor.py (修改:新增 extract() 返回 CodeSkeleton,extract_skeleton 复用之)
├── code_tools.py (新增:outline_file、search_symbols、expand_symbol)
└── languages/
├── python.py (修改:读取 node.start_point / end_point)
├── js_ts.py (修改)
├── go.py (修改)
├── rust.py (修改)
├── java.py (修改)
├── cpp.py (修改:3 处函数提取路径都要改)
└── ...
无新增依赖,使用已安装的 tree-sitter Python 绑定。
skeleton.py — 新增行号字段给 FunctionSig 和 ClassSkeleton 加 line_start、line_end:
@dataclass
class FunctionSig:
name: str
params: str
return_type: str
docstring: str
line_start: int = 0 # 1-indexed,起始行(含)
line_end: int = 0 # 1-indexed,结束行(含)
@dataclass
class ClassSkeleton:
name: str
bases: List[str]
docstring: str
methods: List[FunctionSig] = field(default_factory=list)
line_start: int = 0
line_end: int = 0
默认值 0 保持向后兼容,现有调用方(extract_skeleton、嵌入流水线)无需改动。
to_text() 不改——它服务于 embedding/LLM 输入场景,行号会污染那条路径。outline 的展示由 code_tools.outline_file() 独立实现。
extractor.py — 新增 extract() 返回 CodeSkeleton现有 ASTExtractor.extract_skeleton() 返回的是格式化文本字符串,不是 CodeSkeleton 对象。代码工具需要原始结构,因此在 ASTExtractor 上新增公开方法:
def extract(self, file_name: str, content: str) -> Optional[CodeSkeleton]:
"""Return raw CodeSkeleton or None for unsupported/failed extraction."""
lang = self._detect_language(file_name)
extractor = self._get_extractor(lang)
if extractor is None:
return None
try:
return extractor.extract(file_name, content)
except Exception as e:
logger.warning(
"AST extraction failed for '%s' (language: %s): %s", file_name, lang, e
)
return None
extract_skeleton() 重构为先调 extract()、再 .to_text(verbose),逻辑完全等价。
每个 _extract_function / _extract_class / _extract_struct 在构造 FunctionSig / ClassSkeleton 时多传两个参数:
# tree-sitter node.start_point = (row, col),0-indexed
line_start = node.start_point[0] + 1
line_end = node.end_point[0] + 1
特别注意 cpp.py:该文件有三处函数构造路径——_extract_function_declarator、_extract_function、_extract_function_proto——三处都要传行号。其他语言每个文件改 2 处(函数 + 类/struct)即可。
code_tools.py — 新增模块新建 openviking/parse/parsers/code/ast/code_tools.py,三个纯函数,无 I/O 无异步,输入字符串、返回格式化字符串。I/O 与逻辑分离便于独立测试。
def outline_file(content: str, file_name: str) -> str:
"""返回源文件的符号结构(含行号、总行数)。
内部:ASTExtractor.extract() -> CodeSkeleton -> 专用 outline 格式器。"""
def search_symbols(query: str, files: list[tuple[str, str]]) -> str:
"""在多个 (content, file_name) 中搜索符号名包含 query 的符号
(大小写不敏感子串匹配)。"""
def expand_symbol(content: str, file_name: str, symbol: str) -> str:
"""返回符号完整源码。symbol 支持两种形式:
- 'foo' 匹配任意位置同名函数/类/方法
- 'Foo.bar' 精确匹配 Foo 类下的 bar 方法
同名多个返回第一个;区分大小写。"""
mcp_endpoint.py — 新增三个工具@mcp.tool()
async def code_outline(uri: str) -> str:
"""展示源文件的符号结构——类、函数、方法及其行号范围。
uri 必须是 viking:// URI。"""
@mcp.tool()
async def code_search(query: str, uri: str) -> str:
"""在 viking:// 目录下按名称搜索符号。query 对符号名做大小写不敏感
子串匹配,返回匹配符号及其文件位置和行号。最多扫描 200 个文件。
uri 必填——不提供默认值以避免误扫整个 VikingFS。"""
@mcp.tool()
async def code_expand(uri: str, symbol: str) -> str:
"""返回源文件中指定符号(函数、类或方法)的完整源码。
symbol 支持 'bar' 和 'Foo.bar' 两种形式。"""
仅处理 viking:// URI,通过 service.fs 调用:
viking://resources/owner/repo/src/auth.py
→ service.fs.read(uri, ctx=ctx) # 文件内容
viking://resources/owner/repo/src/
→ service.fs.ls(uri, ctx=ctx, recursive=True, output="original")
返回 dict 列表,字段:name、isDir、uri(camelCase)
辅助函数 _resolve_code_dir(uri, service, ctx) 完成 ls + 按扩展名过滤 + 并发读取。
支持的扩展名(与 extractor._EXT_MAP 对齐):
.py .js .jsx .ts .tsx .java .c .cpp .cc .h .hpp .rs .go .cs .php .lua
code_outline(uri)uri
→ service.fs.read() → content
→ ASTExtractor.extract() → CodeSkeleton(或 None)
→ outline_file() → 格式化字符串
None 时直接返回 "不支持的语言:{file_name}" 或 "解析 {file_name} 失败"。
code_search(query, uri)uri(目录)
→ service.fs.ls(recursive=True, output="original")
→ 按扩展名过滤,截断到 200 个 → list[uri]
→ asyncio.gather + Semaphore(10) 并发 service.fs.read()
→ list[(content, file_name)]
→ 逐文件 ASTExtractor.extract() → list[CodeSkeleton]
→ search_symbols(query, ...) → 格式化字符串
并发模式参考已有 read 工具(mcp_endpoint.py:290)。解析失败的文件跳过、记录 warning,不中断整体搜索。
code_expand(uri, symbol)uri
→ service.fs.read() → content
→ ASTExtractor.extract() → CodeSkeleton
→ expand_symbol() → content 的 [line_start-1 : line_end] 行 + 位置标注
code_outlineoutline_file() 不复用 CodeSkeleton.to_text()(那是嵌入流水线格式),自带格式器:
auth.py [Python, 120 lines]
imports: os, typing.Optional, fastapi.HTTPException
class AuthHandler L18-62
+ __init__(self, secret: str) L20-25
+ authenticate(self, token: str) -> Optional[User] L27-45
+ verify_scope(self, user: User, scope: str) -> bool L47-60
def get_handler() -> AuthHandler L65-70
CodeSkeleton.language(Python/JavaScript/TypeScript 等,保留 extractor 已有 casing)outline_file() 内 content.count("\n") + 1 计算code_search2 matches for "authenticate" (scanned 47 files)
src/auth.py
AuthHandler.authenticate L27-45
authenticate_request L75-88
如果到达 200 文件上限,输出末尾追加 (scanning stopped at 200-file cap; narrow uri to search more)。
code_expand# src/auth.py L27-45
def authenticate(self, token: str) -> Optional[User]:
payload = jwt.decode(token, self.secret)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401)
return db.get(User, user_id)
| 场景 | 工具 | 返回内容 |
|---|---|---|
不支持的语言(扩展名不在 _EXT_MAP) | outline / expand | "不支持的语言:{file_name}" |
| 不支持的语言 | search | 静默跳过该文件 |
| 解析失败 | outline / expand | "解析 {file_name} 失败:{reason}"(明确错误,不 "继续") |
| 解析失败 | search | 记录 warning,跳过该文件,继续 |
| 符号未找到 | expand | "在 {file_name} 中未找到符号 '{symbol}'" |
| URI 不存在 | 全部 | 透传 AGFSNotFoundError 或 FileNotFoundError |
| 目录为空 / 无可解析文件 | search | "在 {uri} 中未找到支持的源文件" |
| 传入非 viking:// URI | 全部 | "仅支持 viking:// URI;本地路径请先 add_resource" |
| 文件 | 改动类型 |
|---|---|
openviking/parse/parsers/code/ast/skeleton.py | 新增 line_start、line_end 字段(默认 0) |
openviking/parse/parsers/code/ast/extractor.py | 新增 extract() 返回 CodeSkeleton;extract_skeleton 重构复用之 |
openviking/parse/parsers/code/ast/languages/python.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/js_ts.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/go.py | 读取节点行号(2 处:function、struct) |
openviking/parse/parsers/code/ast/languages/rust.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/java.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/cpp.py | 读取节点行号(3 处:declarator、function、proto) |
openviking/parse/parsers/code/ast/languages/csharp.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/php.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/languages/lua.py | 读取节点行号(2 处) |
openviking/parse/parsers/code/ast/code_tools.py | 新增文件 |
openviking/server/mcp_endpoint.py | 新增 3 个工具 + viking:// 校验辅助 |