Files
MCP/operation/connector/connector.go
T
Lunny Xiao d7d0cf34d6 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 <codet@commitgo.dev> (GPT-5-Codex)
2026-08-24 00:23:37 -07:00

160 lines
4.6 KiB
Go

// 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
}