Back to Openviking

Code Tools MCP 集成设计文档

docs/design/2026-05-20-code-tools-design.md

0.3.1910.9 KB
Original Source

Code Tools MCP 集成设计文档

日期:2026-05-20 范围:向 OpenViking 新增 code_outlinecode_searchcode_expand 三个 MCP 工具


概述

三个新 MCP 工具通过 OpenViking 现有的 @mcp.tool() 端点向 AI Agent 暴露代码结构导航能力。典型使用顺序:

code_outline  →  查看文件符号结构
code_search   →  跨目录查找符号位置
code_expand   →  展开完整实现代码

仅支持 viking:// URI(不支持直接本地路径),与现有 readgreplist 等工具保持一致,符合服务端既有安全边界(参见 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 绑定。


各组件改动说明

1. skeleton.py — 新增行号字段

FunctionSigClassSkeletonline_startline_end

python
@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() 独立实现。

2. extractor.py — 新增 extract() 返回 CodeSkeleton

现有 ASTExtractor.extract_skeleton() 返回的是格式化文本字符串,不是 CodeSkeleton 对象。代码工具需要原始结构,因此在 ASTExtractor 上新增公开方法:

python
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),逻辑完全等价。

3. 各语言提取器 — 读取节点行号

每个 _extract_function / _extract_class / _extract_struct 在构造 FunctionSig / ClassSkeleton 时多传两个参数:

python
# 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)即可。

4. code_tools.py — 新增模块

新建 openviking/parse/parsers/code/ast/code_tools.py,三个纯函数,无 I/O 无异步,输入字符串、返回格式化字符串。I/O 与逻辑分离便于独立测试。

python
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 方法
    同名多个返回第一个;区分大小写。"""

5. mcp_endpoint.py — 新增三个工具

python
@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' 两种形式。"""

URI 解析

仅处理 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_outline

outline_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 计算
  • 不输出 docstring,行号是导航主信息
2 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_MAPoutline / expand"不支持的语言:{file_name}"
不支持的语言search静默跳过该文件
解析失败outline / expand"解析 {file_name} 失败:{reason}"(明确错误, "继续")
解析失败search记录 warning,跳过该文件,继续
符号未找到expand"在 {file_name} 中未找到符号 '{symbol}'"
URI 不存在全部透传 AGFSNotFoundErrorFileNotFoundError
目录为空 / 无可解析文件search"在 {uri} 中未找到支持的源文件"
传入非 viking:// URI全部"仅支持 viking:// URI;本地路径请先 add_resource"

涉及文件清单

文件改动类型
openviking/parse/parsers/code/ast/skeleton.py新增 line_startline_end 字段(默认 0)
openviking/parse/parsers/code/ast/extractor.py新增 extract() 返回 CodeSkeletonextract_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:// 校验辅助