Bytebase MCP 服务中 search_api 工具的 Schema Lookup 能力设计与实现解析
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
本篇文章基于仓库 docs/plans/2025-12-15-mcp-schema-lookup.md 这份实现计划文档,结合 Bytebase 仓库
backend/api/mcp下的真实源码与测试,完整还原了 MCP(Model Context Protocol)服务中search_api工具新增 schema 查找模式(Schema Lookup)与 protobuf 描述精简(Description Truncation)的全过程。读者将掌握:如何为 MCP 工具扩展输入参数、如何基于 libopenapi 构建 OpenAPI 组件 Schema 索引并支持全名/短名/枚举三种查找方式、如何用简短类型描述替换冗长的 protobuf 文档,以及如何在 MCP 客户端中以search_api → call_api的链路精准构造 API 调用。全文所有代码均取自当前仓库实际实现,可对照源码逐行验证。
背景:为什么需要 Schema Lookup
Bytebase 将自身 API 以 MCP 服务器形式暴露给 AI Agent,核心工具链是search_api(发现端点)与call_api(执行调用)。在引入 schema 查找之前,search_api已经支持四种模式:无参列出全部服务、service浏览某服务下所有端点、operationId查看某个端点的请求/响应 Schema、service + query在服务内搜索。
这份实现计划指出两个真实痛点:
- Agent 无法直接查看消息类型(Message Type)的定义。
operationId模式只能看到某个端点请求/响应体中的字段,当 Agent 需要构造嵌套对象(例如向CreateInstance传入一个bytebase.v1.Instance对象)时,它并不知道Instance类型本身有哪些字段、哪些必填、字段类型是什么。 - protobuf 文档过于冗长。OpenAPI 规格由 protobuf 生成后,
google.protobuf.Timestamp、Duration等内建类型携带大段官方文档(如 "A Timestamp represents a point in time..."),这些冗长描述会占用 Agent 宝贵的上下文窗口,却几乎不提供可执行信息。
因此计划的Goal明确为:为search_api增加schema参数用于查找消息类型,并精简冗长的 protobuf 描述。技术栈为 Go、libopenapi、MCP SDK。
整体架构:OpenAPIIndex 与 search_api 的分工
在进入具体任务前,先看两个核心文件的分工:
- backend/api/mcp/openapi_index.go —— 负责在服务器启动时解析内嵌的 OpenAPI 规格(
//go:embed gen/openapi.yaml,见 backend/api/mcp/gen/openapi.yaml),构建OpenAPIIndex结构:按 operationId、service、关键词建立索引,并提供GetEndpoint、GetServiceEndpoints、Search、GetRequestSchema、GetSchema等方法。 - backend/api/mcp/tool_search.go —— 负责
search_api工具的注册与请求分发,handleSearchAPI依据输入参数的优先级(OperationID>Schema>Service)选择不同的格式化输出函数。
计划中 Task 1 的落点正是这两处:向OpenAPIIndex增加GetSchema方法,并让handleSearchAPI增加schema分支。OpenAPIIndex使用 libopenapi(当前仓库go.mod中版本为v0.38.7)解析 OpenAPI v3 文档,通过doc.Model.Components.Schemas访问组件 Schema 表。
Task 1:为 OpenAPIIndex 增加 GetSchema 方法
第一步:先写失败测试
计划遵循 TDD 流程。在 backend/api/mcp/tool_search_test.go 中添加TestSearchAPISchemaLookup,用全名bytebase.v1.Instance查找:
func TestSearchAPISchemaLookup(t *testing.T) { profile := &config.Profile{Mode: common.ReleaseModeDev} s, err := NewServer(nil, profile, "test-secret") require.NoError(t, err) // Test schema lookup with full name result, _, err := s.handleSearchAPI(context.Background(), nil, SearchInput{ Schema: "bytebase.v1.Instance", }) require.NoError(t, err) require.NotNil(t, result) require.Len(t, result.Content, 1) text := result.Content[0].(*mcpsdk.TextContent).Text require.Contains(t, text, "bytebase.v1.Instance") require.Contains(t, text, "name:") require.Contains(t, text, "engine:") }运行go test -v github.com/bytebase/bytebase/backend/api/mcp -run ^TestSearchAPISchemaLookup$,预期失败——因为此时SearchInput还没有Schema字段。
仓库中实际实现与计划略有演进:当前测试通过
newServerWithStore(newTestServerStore(), profile, "test-secret", nil)构造服务器(见 backend/api/mcp/tool_search_test.go),并且断言字段输出带引号("name":、"engine":),与formatProperty输出的 JSON 风格一致。
第二步:为 SearchInput 增加 Schema 字段
在 backend/api/mcp/tool_search.go 中,SearchInput的完整定义(含注释)如下:
// SearchInput is the input for the search_api tool. type SearchInput struct { // OperationID gets detailed schema for a specific endpoint. // Use this after finding the endpoint you need. OperationID string `json:"operationId,omitempty"` // Schema gets the definition of a message type. // Examples: "bytebase.v1.Instance", "Instance", "Engine" Schema string `json:"schema,omitempty"` // Service filters results to a specific service. // Examples: "SQLService", "DatabaseService", "ProjectService" Service string `json:"service,omitempty"` }字段说明:
| 参数 | 类型 | 作用 | 示例 |
|---|---|---|---|
operationId | string | 查看指定端点的请求/响应 Schema(Detail 模式) | "SQLService/Query" |
schema | string | 查看消息类型定义(Schema 模式,本计划新增) | "bytebase.v1.Instance"、"Instance"、"Engine" |
service | string | 浏览某服务下所有端点 | "SQLService"、"DatabaseService"、"ProjectService" |
query | string | 自由文本搜索端点(计划原始文档包含,当前仓库实现由 service 浏览模式覆盖) | "create database" |
limit | int | 返回结果条数上限(默认 5,最大 50,计划原始文档含此字段) | 10 |
第三步:实现 GetSchema 与 getSchemaByName
计划要求在 backend/api/mcp/openapi_index.go 文件末尾追加两个方法。当前仓库中的实际实现位于 openapi_index.go:
// GetSchema returns the schema properties for a component schema by name. // Supports both full name (bytebase.v1.Instance) and short name (Instance). func (idx *OpenAPIIndex) GetSchema(name string) ([]PropertyInfo, bool) { if idx.doc.Model.Components == nil || idx.doc.Model.Components.Schemas == nil { return nil, false } // Try exact name first if props := idx.getSchemaByName(name); props != nil { return props, true } // Try with bytebase.v1. prefix if !strings.HasPrefix(name, "bytebase.v1.") { fullName := "bytebase.v1." + name if props := idx.getSchemaByName(fullName); props != nil { return props, true } } return nil, false }查找逻辑分两段:先按原样精确匹配,若失败且名称未带bytebase.v1.前缀,则自动补全前缀再匹配一次。这样Instance与bytebase.v1.Instance得到完全相同的结果。
getSchemaByName是核心解析函数,按以下顺序处理:
- 枚举类型:若 Schema 携带
Enum列表,则把全部枚举值合并为单个PropertyInfo{Name: "enum", Type: "string", Description: "值1, 值2, ..."}返回。 - 普通消息:遍历
schema.Properties,为每个属性生成PropertyInfo{Name, Type, Description, Required}。类型推导复用extractPropertyTypeAndDesc(openapi_index.go):$ref引用类型取其末尾段(如#/components/schemas/bytebase.v1.Engine→Engine),数组类型展开为array<元素类型>。 - 排序:最后用
slices.SortFunc按属性名做字典序排序,保证输出稳定、便于 Agent 阅读。
PropertyInfo结构定义在 openapi_index.go:
type PropertyInfo struct { Name string `json:"name"` Type string `json:"type"` Description string `json:"description,omitempty"` Required bool `json:"required,omitempty"` }第四步:handleSearchAPI 增加 schema 分支
在 backend/api/mcp/tool_search.go 中,handleSearchAPI使用switch按优先级分发:
func (s *Server) handleSearchAPI(_ context.Context, _ *mcp.CallToolRequest, input SearchInput) (*mcp.CallToolResult, any, error) { var text string switch { case input.OperationID != "": // Detail mode: get full schema for a specific endpoint text = s.formatEndpointDetail(input.OperationID) case input.Schema != "": // Schema lookup mode: get properties of a message type text = s.formatSchemaDetail(input.Schema) case input.Service == "": // List all services text = s.formatServiceList() default: // List all endpoints in a service (no limit) endpoints := s.openAPIIndex.GetServiceEndpoints(input.Service) ... } return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: text}}, }, nil, nil }第五步:实现 formatSchemaDetail 输出格式化
formatSchemaDetail(tool_search.go)负责把GetSchema的结果渲染成 Agent 易读的文本:
func (s *Server) formatSchemaDetail(schemaName string) string { props, ok := s.openAPIIndex.GetSchema(schemaName) if !ok { return fmt.Sprintf("Unknown schema: %s\n\nUse search_api(operationId=\"...\") to see schemas in request/response bodies.", schemaName) } var sb strings.Builder // Normalize name for display displayName := schemaName if !strings.HasPrefix(schemaName, "bytebase.v1.") { displayName = "bytebase.v1." + schemaName } fmt.Fprintf(&sb, "## %s\n\n", displayName) // Check if it's an enum if len(props) == 1 && props[0].Name == "enum" { sb.WriteString("**Enum values:** ") sb.WriteString(props[0].Description) sb.WriteString("\n") return sb.String() } for _, prop := range props { s.formatProperty(&sb, prop) } return sb.String() }关键设计点:
- 名称归一化:无论调用方传全名还是短名,标题统一显示为
bytebase.v1.Xxx,输出稳定; - 枚举特判:单个名为
enum的属性直接渲染为**Enum values:** v1, v2, ...; - 未命中提示:返回
Unknown schema: xxx并引导 Agent 改用operationId模式查看请求/响应体中的 Schema 引用。
第六步:运行测试
go test -v github.com/bytebase/bytebase/backend/api/mcp -run ^TestSearchAPISchemaLookup$,预期 PASS。
Task 2:短名查找、未命中与枚举的回归测试
计划继续补充三个测试用例,覆盖 Schema 模式的边界情况:
func TestSearchAPISchemaLookupShortName(t *testing.T) { // Test schema lookup with short name result, _, err := s.handleSearchAPI(context.Background(), nil, SearchInput{ Schema: "Instance", }) ... text := result.Content[0].(*mcpsdk.TextContent).Text require.Contains(t, text, "bytebase.v1.Instance") require.Contains(t, text, "name:") } func TestSearchAPISchemaLookupNotFound(t *testing.T) { // Test schema lookup with unknown name result, _, err := s.handleSearchAPI(context.Background(), nil, SearchInput{ Schema: "NonExistentSchema", }) ... text := result.Content[0].(*mcpsdk.TextContent).Text require.Contains(t, text, "Unknown schema") } func TestSearchAPISchemaLookupEnum(t *testing.T) { // Test enum schema lookup result, _, err := s.handleSearchAPI(context.Background(), nil, SearchInput{ Schema: "Engine", }) ... text := result.Content[0].(*mcpsdk.TextContent).Text require.Contains(t, text, "Enum values:") }这三个测试分别验证:短名补全路径(Instance→bytebase.v1.Instance)、未命中路径(返回Unknown schema提示)、枚举路径(Engine返回Enum values:)。运行go test -v github.com/bytebase/bytebase/backend/api/mcp -run ^TestSearchAPISchemaLookup全部通过。当前仓库中这些测试均已落地,见 tool_search_test.go。
Task 3:protobuf 类型描述的截断与精简
这是本计划第二个核心目标。OpenAPI 规格从 protobuf 生成后,内建类型带冗长官方文档,需要替换为一行简短说明。
第一步:失败测试先行
func TestSearchAPIProtobufDescriptionTruncation(t *testing.T) { profile := &config.Profile{Mode: common.ReleaseModeDev} s, err := NewServer(nil, profile, "test-secret") require.NoError(t, err) result, _, err := s.handleSearchAPI(context.Background(), nil, SearchInput{ OperationID: "InstanceService/CreateInstance", }) ... // Should NOT contain verbose protobuf documentation require.NotContains(t, text, "A Timestamp represents a point in time") require.NotContains(t, text, "A Duration represents a signed") // Should contain short description if strings.Contains(text, "google.protobuf.Timestamp") { require.Contains(t, text, "ISO 8601") } }此时预期失败:formatProperty直接把属性描述原样输出,冗长文档仍在。
第二步:typeDescriptions 映射表
在 openapi_index.go 中定义全局映射(当前仓库已实现):
// typeDescriptions provides concise descriptions for known types. // These replace verbose protobuf documentation. var typeDescriptions = map[string]string{ "google.protobuf.Timestamp": `ISO 8601 format, e.g. "2024-01-15T01:30:15Z"`, "google.protobuf.Duration": `e.g. "3.5s" or "1h30m"`, "google.protobuf.FieldMask": `e.g. "title,engine"`, "google.protobuf.Empty": "empty message", "google.protobuf.Any": "any JSON value", "google.protobuf.Struct": "JSON object", "google.protobuf.Value": "any JSON value", } // GetTypeDescription returns a concise description for known types. func GetTypeDescription(typeName string) (string, bool) { desc, ok := typeDescriptions[typeName] return desc, ok }这张表覆盖了 Agent 构造请求体时最常遇到的 7 个 protobuf 内建类型,每个都给出可直接用于构造 JSON 的语义(如Timestamp直接告诉 Agent 传 ISO 8601 字符串)。
第三步:改造 formatProperty
在 tool_search.go 中,formatProperty的输出顺序调整为:先查短描述映射,命中即用;否则清洗并截断原描述:
func (*Server) formatProperty(sb *strings.Builder, prop PropertyInfo) { required := "" if prop.Required { required = " (required)" } desc := "" // Check if type has a known short description if shortDesc, ok := GetTypeDescription(prop.Type); ok { desc = fmt.Sprintf(" // %s", shortDesc) } else if prop.Description != "" { // Remove newlines and truncate long descriptions cleanDesc := strings.ReplaceAll(prop.Description, "\n", " ") cleanDesc = strings.ReplaceAll(cleanDesc, "\r", "") // Truncate at 100 chars if truncated, ok := common.TruncateString(cleanDesc, 97); ok { cleanDesc = truncated + "..." } desc = fmt.Sprintf(" // %s", cleanDesc) } sb.WriteString(" \"") sb.WriteString(prop.Name) sb.WriteString("\": ") sb.WriteString(prop.Type) sb.WriteString(required) sb.WriteString(desc) sb.WriteString("\n") }这里有两个细节值得注意:
- 截断函数是 Unicode 安全的:
common.TruncateString(backend/common/util.go)按 rune 迭代而非按字节截断,避免切出半个 UTF-8 字符。截断上限为 97 字符 +...,正好 100 字符。 - 输出为 JSON 风格:每行形如
"name": string (required) // 描述,配合formatEndpointDetail中的 ```json 代码块,Agent 可以直接把返回内容当作 JSON 骨架使用。
第四步:验证
go test -v github.com/bytebase/bytebase/backend/api/mcp -run ^TestSearchAPIProtobufDescriptionTruncation$预期 PASS;再运行go test -v github.com/bytebase/bytebase/backend/api/mcp确认全部测试通过。
Task 4:更新 search_api 工具描述
search_api的Description是 MCP 工具发现机制的一部分——Agent 通过它理解每个参数何时使用。计划要求替换 tool_search.go 中的searchAPIDescription常量:
const searchAPIDescription = `Discover Bytebase API endpoints. **Always call before call_api - never guess schemas.** | Mode | Parameters | Result | |------|------------|--------| | List | (none) | All services | | Browse | service="SQLService" | All endpoints in service | | Details | operationId="SQLService/Query" | Request/response schema | | Schema | schema="Instance" | Message type definition | **Workflow:** search_api() → search_api(service="...") → search_api(operationId="...") → call_api(...)`描述表格把五种模式(List / Browse / Search / Filter / Details / Schema)与各自参数、返回结果一一对应,并明确工作流链路,配合 tool_call.go 中call_api的描述("Use search_api first to get operationId and schema."),形成完整的"先发现、后调用"闭环。改动后用golangci-lint run --allow-parallel-runners ./backend/api/mcp/...检查。
Task 5:手动验证清单
计划最后给出端到端验证步骤。先构建二进制:
go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go启动 Bytebase 后,在 MCP 客户端中逐项验证:
| 调用 | 预期输出 |
|---|---|
search_api(schema="Instance") | 显示 Instance 的字段定义(名称、类型、必填、描述) |
search_api(schema="bytebase.v1.Instance") | 与上一条完全相同(短名归一化) |
search_api(schema="Engine") | 显示Enum values:及全部枚举值 |
search_api(operationId="InstanceService/CreateInstance") | 请求/响应 Schema 中的 protobuf 字段使用简短描述(如 ISO 8601) |
search_api 与 call_api 的完整协作链路
Schema Lookup 能力的最终价值体现在与call_api的配合上。基于 tool_call.go 的源码,一个完整的工作流是:
search_api()—— 列出全部服务,确认目标服务(如InstanceService);search_api(service="InstanceService")—— 浏览该服务下的端点,找到CreateInstance;search_api(operationId="InstanceService/CreateInstance")—— 查看该端点的请求/响应 Schema;search_api(schema="Instance")—— 本次计划新增的能力,查看Instance消息类型本身的字段定义,确定哪些字段必填、字段类型与参考格式(如Timestamp用 ISO 8601);call_api(operationId="InstanceService/CreateInstance", body={...})—— 按查到的 Schema 精确构造请求体并执行。
call_api在 handleCallAPI 中同样通过openAPIIndex.GetEndpoint解析 operationId,未知操作会返回unknown operation, use search_api to find valid operations的引导式错误——两个工具共享同一个 OpenAPI 索引,保证发现与执行的一致性。
实现要点总结
| 维度 | 关键结论 |
|---|---|
| 核心改动文件 | backend/api/mcp/openapi_index.go(索引与类型描述)、backend/api/mcp/tool_search.go(参数与格式化)、backend/api/mcp/tool_search_test.go(测试) |
| 新增 API | OpenAPIIndex.GetSchema(name)、getSchemaByName(name)、formatSchemaDetail(name)、GetTypeDescription(type) |
| 查找策略 | 精确匹配 → 自动补全bytebase.v1.前缀 → 未命中返回Unknown schema引导 |
| 枚举处理 | 枚举 Schema 折叠为单一enum属性,渲染为Enum values:列表 |
| 描述精简 | 7 个 protobuf 内建类型走短描述映射;其余描述去除换行、截断至 100 字符(Unicode 安全) |
| 测试验证 | go test -v github.com/bytebase/bytebase/backend/api/mcp全量通过 |
从这份实现计划到仓库中的最终代码可以看出:Schema Lookup 功能以最小的侵入面(一个参数 + 一个索引方法 + 一个格式化函数)显著增强了search_api工具的可用性,使 AI Agent 无需猜测即可精确构造嵌套消息类型,是 MCP API 发现链路中"不猜 Schema"原则的关键落地。文中所有行号对应的实现均可直接在 backend/api/mcp 目录下对照阅读。
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考