mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-28 11:07: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)
156 lines
4.6 KiB
Go
156 lines
4.6 KiB
Go
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")
|
|
}
|
|
}
|