mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-28 02:57:44 +00:00
d7d0cf34d6
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)
160 lines
4.6 KiB
Go
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
|
|
}
|