Compare commits

..

1 Commits

Author SHA1 Message Date
Lunny Xiao 6694ec6873 fix(main): only trust build version when major matches module path
debug.ReadBuildInfo() can report a version whose major version does
not match the module path's major version suffix (e.g. a v2 tag on a
module without a /v2 path), which is unreliable per Go's module
versioning rules. Extract the decision into resolveVersion() and only
accept the build version when its major matches the module path's
implied major version, otherwise fall back to the dev version.

Fixes #231.

Co-Authored-By: Codet <codet@commitgo.dev> (GPT-5-Codex)
2026-08-23 23:35:02 -07:00
9 changed files with 119 additions and 353 deletions
-2
View File
@@ -191,8 +191,6 @@ Once configured, try `list all my repositories` in the chat box.
| get_release | release | Read | Get a release by ID | | get_release | release | Read | Get a release by ID |
| get_latest_release | release | Read | Get the latest release | | get_latest_release | release | Read | Get the latest release |
| list_releases | release | Read | List repository releases | | list_releases | release | Read | List repository releases |
| search | connector | Read | Search Gitea repositories by keyword for ChatGPT-style connectors |
| fetch | connector | Read | Fetch a file's decoded content and metadata for ChatGPT-style connectors |
> **Note:** Several tools are consolidated, action-based tools, a single tool exposes multiple operations through a `method` parameter. Tools with `Write` access are hidden when the server runs in read-only mode (`-r` / `GITEA_READONLY`), and the exposed tool set can be filtered by scope with `-S` / `--scope` (`GITEA_SCOPES`) and/or by individual tool name with `-O` / `--tools` (`GITEA_TOOLS`). > **Note:** Several tools are consolidated, action-based tools, a single tool exposes multiple operations through a `method` parameter. Tools with `Write` access are hidden when the server runs in read-only mode (`-r` / `GITEA_READONLY`), and the exposed tool set can be filtered by scope with `-S` / `--scope` (`GITEA_SCOPES`) and/or by individual tool name with `-O` / `--tools` (`GITEA_TOOLS`).
-2
View File
@@ -191,8 +191,6 @@ Cursor 等客户端可使用 stdio 命令:
| get_release | release | 读取 | 按 ID 获取版本发布 | | get_release | release | 读取 | 按 ID 获取版本发布 |
| get_latest_release | release | 读取 | 获取最新版本发布 | | get_latest_release | release | 读取 | 获取最新版本发布 |
| list_releases | release | 读取 | 列出仓库版本发布 | | list_releases | release | 读取 | 列出仓库版本发布 |
| search | connector | 读取 | 为 ChatGPT 风格的连接器按关键字搜索 Gitea 仓库 |
| fetch | connector | 读取 | 为 ChatGPT 风格的连接器获取文件的解码内容与元数据 |
> **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope``GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools``GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。 > **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope``GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools``GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。
-2
View File
@@ -191,8 +191,6 @@ Cursor 等客戶端可使用 stdio 命令:
| get_release | release | 讀取 | 依 ID 取得版本發布 | | get_release | release | 讀取 | 依 ID 取得版本發布 |
| get_latest_release | release | 讀取 | 取得最新版本發布 | | get_latest_release | release | 讀取 | 取得最新版本發布 |
| list_releases | release | 讀取 | 列出倉庫版本發布 | | list_releases | release | 讀取 | 列出倉庫版本發布 |
| search | connector | 讀取 | 為 ChatGPT 風格的連接器依關鍵字搜尋 Gitea 倉庫 |
| fetch | connector | 讀取 | 為 ChatGPT 風格的連接器取得檔案的解碼內容與中繼資料 |
> **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope``GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools``GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。 > **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope``GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools``GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。
+57 -2
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"path"
"runtime/debug" "runtime/debug"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/cmd" "gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
@@ -11,13 +14,65 @@ var Version = "dev"
func init() { func init() {
if Version == "dev" { if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { if info, ok := debug.ReadBuildInfo(); ok {
Version = info.Main.Version Version = resolveVersion(Version, info)
} }
} }
flag.Version = Version flag.Version = Version
} }
// resolveVersion returns the version reported by debug.ReadBuildInfo when its
// major version matches the major version encoded in the module path (e.g.
// "/v2" suffix). Otherwise it falls back to devVersion, since Go's module
// versioning rules make a mismatched major version untrustworthy (see #231).
func resolveVersion(devVersion string, info *debug.BuildInfo) string {
if info == nil {
return devVersion
}
buildVersion := info.Main.Version
if buildVersion == "" || buildVersion == "(devel)" {
return devVersion
}
buildMajor := majorVersionOf(buildVersion)
pathMajor := majorVersionFromModulePath(info.Main.Path)
if buildMajor != pathMajor {
return devVersion
}
return buildVersion
}
// majorVersionOf extracts the numeric major version from a semver-like
// string such as "v1.2.3", returning 0 if it cannot be parsed.
func majorVersionOf(version string) int {
version = strings.TrimPrefix(version, "v")
dot := strings.IndexByte(version, '.')
if dot >= 0 {
version = version[:dot]
}
major, err := strconv.Atoi(version)
if err != nil {
return 0
}
return major
}
// majorVersionFromModulePath returns the major version encoded in a module
// path's "/vN" suffix, or 1 if the module path has no such suffix (as is the
// case for v0 and v1 modules).
func majorVersionFromModulePath(modulePath string) int {
suffix := path.Base(modulePath)
if len(suffix) < 2 || suffix[0] != 'v' {
return 1
}
major, err := strconv.Atoi(suffix[1:])
if err != nil {
return 1
}
return major
}
func main() { func main() {
cmd.Execute() cmd.Execute()
} }
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"runtime/debug"
"testing"
)
func TestResolveVersion(t *testing.T) {
cases := []struct {
name string
dev string
info *debug.BuildInfo
want string
}{
{
name: "nil build info falls back to dev version",
dev: "dev",
info: nil,
want: "dev",
},
{
name: "devel version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "(devel)"}},
want: "dev",
},
{
name: "empty version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: ""}},
want: "dev",
},
{
name: "v1 version accepted for module path without major suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v1.2.3"}},
want: "v1.2.3",
},
{
name: "v2 version rejected when module path has no /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v2.0.0"}},
want: "dev",
},
{
name: "v2 version accepted when module path has /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp/v2", Version: "v2.0.0"}},
want: "v2.0.0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveVersion(tc.dev, tc.info)
if got != tc.want {
t.Errorf("resolveVersion(%q, %+v) = %q, want %q", tc.dev, tc.info, got, tc.want)
}
})
}
}
-159
View File
@@ -1,159 +0,0 @@
// Package connector provides the "search" and "fetch" tools expected by
// ChatGPT-style MCP connectors (https://platform.openai.com/docs/mcp), on
// top of the same Gitea SDK calls the search and file domains already use.
package connector
import (
"context"
"encoding/base64"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Tool holds the connector tools (scope "connector"): search and fetch.
var Tool = tool.New("connector")
const (
SearchToolName = "search"
FetchToolName = "fetch"
)
var (
SearchTool = tool.NewDefinition(
SearchToolName,
"Search Gitea repositories by keyword. Returns concise entries suitable for ChatGPT-style connectors.",
annotation.ReadOnly("Search"),
tool.String("query", tool.Required(), tool.Description("search keyword")),
tool.String("owner", tool.Description("filter results to repositories owned by this user")),
tool.String("org", tool.Description("filter results to repositories owned by this organization")),
tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
)
FetchTool = tool.NewDefinition(
FetchToolName,
"Fetch a file's decoded content and metadata from a Gitea repository. Suitable for ChatGPT-style connectors.",
annotation.ReadOnly("Fetch"),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.String("path", tool.Required()),
tool.String("ref", tool.Description("branch, tag, or commit SHA")),
)
)
func init() {
Tool.RegisterRead(tool.ServerTool{
Tool: SearchTool,
Handler: SearchFn,
})
Tool.RegisterRead(tool.ServerTool{
Tool: FetchTool,
Handler: FetchFn,
})
}
// SearchFn searches Gitea repositories by keyword, optionally scoped to an
// owner or organization, and returns concise entries.
func SearchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
query, err := params.GetString(args, "query")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
ownerName := params.GetOptionalString(args, "owner", "")
if ownerName == "" {
ownerName = params.GetOptionalString(args, "org", "")
}
var ownerID int64
if ownerName != "" {
owner, _, err := client.Users.GetUserInfo(ctx, ownerName)
if err != nil {
return to.ErrorResult(fmt.Errorf("resolve owner %q err: %v", ownerName, err))
}
ownerID = owner.ID
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.SearchRepoOptions{
Keyword: query,
OwnerID: ownerID,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
repos, _, err := client.Repositories.SearchRepos(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search repos err: %v", err))
}
return to.TextResult(slimSearchResults(repos))
}
// FetchFn fetches a file's content and decodes it, returning the decoded
// content alongside path, sha, size, and encoding metadata.
func FetchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
ref := params.GetOptionalString(args, "ref", "")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
content, _, err := client.Repositories.GetContents(ctx, owner, repo, ref, filePath)
if err != nil {
return to.ErrorResult(fmt.Errorf("get file err: %v", err))
}
decoded, err := decodeContent(content)
if err != nil {
return to.ErrorResult(err)
}
return to.TextResult(map[string]any{
"path": content.Path,
"sha": content.SHA,
"size": content.Size,
"encoding": stringOrEmpty(content.Encoding),
"content": decoded,
})
}
func decodeContent(c *gitea_sdk.ContentsResponse) (string, error) {
if c.Content == nil {
return "", nil
}
raw, err := base64.StdEncoding.DecodeString(*c.Content)
if err != nil {
return "", fmt.Errorf("decode base64 content err: %v", err)
}
return string(raw), nil
}
func stringOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
-155
View File
@@ -1,155 +0,0 @@
package connector
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func withTestServer(t *testing.T, handler http.HandlerFunc) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
origHost, origToken := flag.Host, flag.Token
flag.Host, flag.Token = server.URL, ""
t.Cleanup(func() { flag.Host, flag.Token = origHost, origToken })
}
func resultText(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
text, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("result content = %T, want *mcp.TextContent", result.Content[0])
}
return text.Text
}
func TestSearchFnReturnsConciseRepoEntries(t *testing.T) {
withTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/version") {
_, _ = w.Write([]byte(`{"version":"1.26.0"}`))
return
}
if !strings.HasSuffix(r.URL.Path, "/repos/search") {
t.Fatalf("unexpected request path %q", r.URL.Path)
}
if q := r.URL.Query().Get("q"); q != "gitea-mcp" {
t.Fatalf("query q = %q, want gitea-mcp", q)
}
_, _ = w.Write([]byte(`{"data":[{"id":42,"name":"gitea-mcp","full_name":"gitea/gitea-mcp","html_url":"https://gitea.com/gitea/gitea-mcp","description":"MCP server"}]}`))
})
result, err := SearchFn(context.Background(), map[string]any{"query": "gitea-mcp"})
if err != nil {
t.Fatalf("SearchFn() error = %v", err)
}
var entries []map[string]any
if err := json.Unmarshal([]byte(resultText(t, result)), &entries); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if len(entries) != 1 {
t.Fatalf("len(entries) = %d, want 1", len(entries))
}
entry := entries[0]
if entry["id"] != float64(42) {
t.Errorf("id = %v, want 42", entry["id"])
}
if entry["name"] != "gitea-mcp" {
t.Errorf("name = %v, want gitea-mcp", entry["name"])
}
if entry["url"] != "https://gitea.com/gitea/gitea-mcp" {
t.Errorf("url = %v, want the repo html_url", entry["url"])
}
if entry["html_url"] != "https://gitea.com/gitea/gitea-mcp" {
t.Errorf("html_url = %v, want the repo html_url", entry["html_url"])
}
}
func TestSearchFnRequiresQuery(t *testing.T) {
result, err := SearchFn(context.Background(), map[string]any{})
if err != nil {
t.Fatalf("SearchFn() error = %v", err)
}
if !result.IsError {
t.Fatal("SearchFn() result.IsError = false, want true for a missing query")
}
}
func TestSearchFnResolvesOwnerFilterToOwnerID(t *testing.T) {
withTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/version"):
_, _ = w.Write([]byte(`{"version":"1.26.0"}`))
case strings.HasSuffix(r.URL.Path, "/users/octo"):
_, _ = w.Write([]byte(`{"id":7,"login":"octo"}`))
case strings.HasSuffix(r.URL.Path, "/repos/search"):
if uid := r.URL.Query().Get("uid"); uid != "7" {
t.Fatalf("uid = %q, want 7", uid)
}
_, _ = w.Write([]byte(`{"data":[]}`))
default:
t.Fatalf("unexpected request path %q", r.URL.Path)
}
})
if _, err := SearchFn(context.Background(), map[string]any{"query": "demo", "owner": "octo"}); err != nil {
t.Fatalf("SearchFn() error = %v", err)
}
}
func TestFetchFnReturnsDecodedContentAndMetadata(t *testing.T) {
withTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/contents/") {
t.Fatalf("unexpected request path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"name":"README.md","path":"README.md","sha":"abc123","type":"file","size":11,"encoding":"base64","content":"aGVsbG8gd29ybGQ="}`))
})
result, err := FetchFn(context.Background(), map[string]any{
"owner": "octo",
"repo": "demo",
"path": "README.md",
})
if err != nil {
t.Fatalf("FetchFn() error = %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(resultText(t, result)), &got); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if got["content"] != "hello world" {
t.Errorf("content = %v, want decoded %q", got["content"], "hello world")
}
if got["path"] != "README.md" {
t.Errorf("path = %v, want README.md", got["path"])
}
if got["sha"] != "abc123" {
t.Errorf("sha = %v, want abc123", got["sha"])
}
if got["size"] != float64(11) {
t.Errorf("size = %v, want 11", got["size"])
}
if got["encoding"] != "base64" {
t.Errorf("encoding = %v, want base64", got["encoding"])
}
}
func TestFetchFnRequiresPath(t *testing.T) {
result, err := FetchFn(context.Background(), map[string]any{"owner": "octo", "repo": "demo"})
if err != nil {
t.Fatalf("FetchFn() error = %v", err)
}
if !result.IsError {
t.Fatal("FetchFn() result.IsError = false, want true for a missing path")
}
}
-29
View File
@@ -1,29 +0,0 @@
package connector
import (
gitea_sdk "gitea.dev/sdk"
)
// slimSearchResult is the concise entry shape ChatGPT-style connectors
// expect from search: an id, a title, and a url to fetch it by.
func slimSearchResult(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"name": r.Name,
"full_name": r.FullName,
"description": r.Description,
"url": r.HTMLURL,
"html_url": r.HTMLURL,
}
}
func slimSearchResults(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimSearchResult(r))
}
return out
}
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
"gitea.com/gitea/gitea-mcp/operation/actions" "gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/connector"
"gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/milestone"
@@ -48,7 +47,7 @@ var (
domainTools = []*tool.Tool{ domainTools = []*tool.Tool{
user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool,
label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool,
version.Tool, wiki.Tool, timetracking.Tool, connector.Tool, version.Tool, wiki.Tool, timetracking.Tool,
repo.FileTool, repo.BranchTool, repo.TagTool, repo.CommitTool, repo.ReleaseTool, repo.FileTool, repo.BranchTool, repo.TagTool, repo.CommitTool, repo.ReleaseTool,
} }
) )