From d7d0cf34d669e99f15b79ad8de519aa5603fd194 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 24 Aug 2026 00:23:37 -0700 Subject: [PATCH] feat(connector): add search and fetch tools for ChatGPT connectors Add a new "connector" domain exposing exactly-named "search" and "fetch" MCP tools for ChatGPT-style connector compatibility (gitea#84). search looks up repositories via the existing SearchRepos SDK call, optionally resolving an owner/org name to an owner ID, and returns concise entries (id, name, url, html_url). fetch reuses GetContents to read a file and decodes its base64 content, returning path, sha, size, encoding and the decoded content. Registers connector.Tool in operation.go and documents the two tools in all three README tables. Co-Authored-By: Codet (GPT-5-Codex) --- README.md | 2 + README.zh-cn.md | 2 + README.zh-tw.md | 2 + operation/connector/connector.go | 159 ++++++++++++++++++++++++++ operation/connector/connector_test.go | 155 +++++++++++++++++++++++++ operation/connector/slim.go | 29 +++++ operation/operation.go | 3 +- 7 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 operation/connector/connector.go create mode 100644 operation/connector/connector_test.go create mode 100644 operation/connector/slim.go diff --git a/README.md b/README.md index 91311de..bb7477a 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ Once configured, try `list all my repositories` in the chat box. | get_release | release | Read | Get a release by ID | | get_latest_release | release | Read | Get the latest release | | 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`). diff --git a/README.zh-cn.md b/README.zh-cn.md index deb926a..e9778ac 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -191,6 +191,8 @@ Cursor 等客户端可使用 stdio 命令: | get_release | release | 读取 | 按 ID 获取版本发布 | | get_latest_release | release | 读取 | 获取最新版本发布 | | list_releases | release | 读取 | 列出仓库版本发布 | +| search | connector | 读取 | 为 ChatGPT 风格的连接器按关键字搜索 Gitea 仓库 | +| fetch | connector | 读取 | 为 ChatGPT 风格的连接器获取文件的解码内容与元数据 | > **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope`(`GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools`(`GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。 diff --git a/README.zh-tw.md b/README.zh-tw.md index 9dc812e..24c7770 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -191,6 +191,8 @@ Cursor 等客戶端可使用 stdio 命令: | get_release | release | 讀取 | 依 ID 取得版本發布 | | get_latest_release | release | 讀取 | 取得最新版本發布 | | list_releases | release | 讀取 | 列出倉庫版本發布 | +| search | connector | 讀取 | 為 ChatGPT 風格的連接器依關鍵字搜尋 Gitea 倉庫 | +| fetch | connector | 讀取 | 為 ChatGPT 風格的連接器取得檔案的解碼內容與中繼資料 | > **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope`(`GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools`(`GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。 diff --git a/operation/connector/connector.go b/operation/connector/connector.go new file mode 100644 index 0000000..fc3e831 --- /dev/null +++ b/operation/connector/connector.go @@ -0,0 +1,159 @@ +// 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 +} diff --git a/operation/connector/connector_test.go b/operation/connector/connector_test.go new file mode 100644 index 0000000..59800e5 --- /dev/null +++ b/operation/connector/connector_test.go @@ -0,0 +1,155 @@ +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") + } +} diff --git a/operation/connector/slim.go b/operation/connector/slim.go new file mode 100644 index 0000000..26d373f --- /dev/null +++ b/operation/connector/slim.go @@ -0,0 +1,29 @@ +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 +} diff --git a/operation/operation.go b/operation/operation.go index 610d081..2f7ae06 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -14,6 +14,7 @@ import ( "time" "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/label" "gitea.com/gitea/gitea-mcp/operation/milestone" @@ -47,7 +48,7 @@ var ( domainTools = []*tool.Tool{ user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool, - version.Tool, wiki.Tool, timetracking.Tool, + version.Tool, wiki.Tool, timetracking.Tool, connector.Tool, repo.FileTool, repo.BranchTool, repo.TagTool, repo.CommitTool, repo.ReleaseTool, } )