mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-27 02:27:45 +00:00
refactor: replace mcp-go with the official MCP Go SDK (#223)
## Summary Replace `github.com/mark3labs/mcp-go` with the official `github.com/modelcontextprotocol/go-sdk v1.7.0`. All 54 existing tools, scopes, CLI, `stdio` and HTTP (`/mcp`) modes, and per-request authentication remain supported. This PR is limited to the SDK equivalence migration; the stateless 2026 HTTP transport is deferred to a follow-up PR. ## Verification - Tool definitions were compared with `main` and matched for all 54 tools. - `make lint`, `make fmt`, `go test -count=1 -race ./...`, `make build`, and `make tidy` pass. --------- Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/223 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// TestToolContract checks the properties every exposed tool must hold, rather
|
||||
// than a snapshot of the current surface, so adding a tool needs no fixture
|
||||
// update and a malformed schema fails here instead of panicking in AddTool.
|
||||
func TestToolContract(t *testing.T) {
|
||||
scopeByName := map[string]string{}
|
||||
seenScopes := map[string]struct{}{}
|
||||
for _, domain := range domainTools {
|
||||
scope := domain.Scope()
|
||||
if scope == "" {
|
||||
t.Error("domainTools contains a domain with an empty scope")
|
||||
}
|
||||
// Tools() filters one domain by exactly one scope name, so a shared
|
||||
// scope would make --scope select more than the caller asked for.
|
||||
if _, duplicate := seenScopes[scope]; duplicate {
|
||||
t.Errorf("domainTools contains a duplicate scope %q", scope)
|
||||
}
|
||||
seenScopes[scope] = struct{}{}
|
||||
|
||||
for _, registered := range domain.ReadTools() {
|
||||
assertToolContract(t, scope, registered.Tool, true, scopeByName)
|
||||
}
|
||||
for _, registered := range domain.WriteTools() {
|
||||
assertToolContract(t, scope, registered.Tool, false, scopeByName)
|
||||
}
|
||||
}
|
||||
if len(scopeByName) == 0 {
|
||||
t.Fatal("no tools are registered")
|
||||
}
|
||||
}
|
||||
|
||||
func assertToolContract(t *testing.T, scope string, definition *mcp.Tool, readOnly bool, scopeByName map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
t.Run(definition.Name, func(t *testing.T) {
|
||||
if previous, duplicate := scopeByName[definition.Name]; duplicate {
|
||||
t.Errorf("tool name is already registered in scope %q; AddTool would silently replace it", previous)
|
||||
}
|
||||
scopeByName[definition.Name] = scope
|
||||
|
||||
// Strict MCP clients reject a tools/list entry without a description.
|
||||
if definition.Description == "" {
|
||||
t.Error("tool has no description")
|
||||
}
|
||||
|
||||
// A write tool registered as read stays exposed under --read-only.
|
||||
if definition.Annotations == nil || definition.Annotations.ReadOnlyHint != readOnly {
|
||||
t.Errorf("annotations = %+v, want readOnlyHint %v", definition.Annotations, readOnly)
|
||||
}
|
||||
|
||||
schema := decodeJSON(t, definition.InputSchema)
|
||||
if schema["type"] != "object" {
|
||||
t.Fatalf("input schema type = %v, want object", schema["type"])
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("input schema properties = %T, want a JSON object", schema["properties"])
|
||||
}
|
||||
|
||||
for name, raw := range properties {
|
||||
property, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("property %q = %T, want a JSON object", name, raw)
|
||||
continue
|
||||
}
|
||||
assertPropertyContract(t, name, property)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertPropertyContract(t *testing.T, name string, property map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
propertyType, ok := property["type"].(string)
|
||||
if !ok {
|
||||
t.Errorf("property %q has no type", name)
|
||||
return
|
||||
}
|
||||
|
||||
enum, hasEnum := property["enum"].([]any)
|
||||
if _, declared := property["enum"]; declared && len(enum) == 0 {
|
||||
t.Errorf("property %q has an empty enum", name)
|
||||
}
|
||||
|
||||
defaultValue, hasDefault := property["default"]
|
||||
if !hasDefault {
|
||||
return
|
||||
}
|
||||
if !matchesJSONType(defaultValue, propertyType) {
|
||||
t.Errorf("property %q default %#v is not a %s", name, defaultValue, propertyType)
|
||||
}
|
||||
if hasEnum && !slices.Contains(enum, defaultValue) {
|
||||
t.Errorf("property %q default %#v is not one of its enum values %#v", name, defaultValue, enum)
|
||||
}
|
||||
}
|
||||
|
||||
func matchesJSONType(value any, propertyType string) bool {
|
||||
switch propertyType {
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "number":
|
||||
_, ok := value.(float64)
|
||||
return ok
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "array":
|
||||
_, ok := value.([]any)
|
||||
return ok
|
||||
case "object":
|
||||
_, ok := value.(map[string]any)
|
||||
return ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON round-trips through JSON so the assertions see what an MCP client
|
||||
// receives rather than the Go values behind it.
|
||||
func decodeJSON(t *testing.T, value any) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
Reference in New Issue
Block a user