mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-03 15:49:23 +02:00
refactor!: replace mcp-go with the official MCP Go SDK
- Swap the mark3labs MCP dependency for modelcontextprotocol/go-sdk v1.7.0 - Add a declarative tool definition and JSON Schema builder to the tool package - Adapt registered tools to the official low-level handler, recovering panics and mapping failures to JSON-RPC errors - Narrow tool handlers to take a plain argument map instead of an SDK request type - Rewire stdio and HTTP transports onto the official server, moving Authorization parsing into receiving middleware - Add a golden contract test that locks the exposed tool schemas, plus SDK integration and helper tests - Add a test target and run it in the pull request workflow BREAKING CHANGE: The exported helpers change signature. Tool.RegisterRead and Tool.RegisterWrite now take tool.ServerTool instead of server.ServerTool, and the annotation constructors return *mcp.ToolAnnotations from the official SDK. Callers must build tool definitions with tool.NewDefinition and handlers with the func(context.Context, map[string]any) signature. The 30-second SSE heartbeat is removed because the official SDK has no equivalent. ServerOptions.KeepAlive is deliberately not used as a substitute, since it sends MCP ping requests and ping is removed in protocol 2026. HTTP stays stateless=false, so new clients negotiate at most 2025-11-25. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const updateToolContractEnv = "UPDATE_TOOL_CONTRACT"
|
||||
|
||||
type toolContract struct {
|
||||
Scope string `json:"scope"`
|
||||
Access string `json:"access"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema any `json:"inputSchema"`
|
||||
Annotations contractAnnotations `json:"annotations"`
|
||||
}
|
||||
|
||||
type contractAnnotations struct {
|
||||
Title string `json:"title"`
|
||||
ReadOnlyHint bool `json:"readOnlyHint"`
|
||||
DestructiveHint bool `json:"destructiveHint"`
|
||||
IdempotentHint bool `json:"idempotentHint"`
|
||||
OpenWorldHint bool `json:"openWorldHint"`
|
||||
}
|
||||
|
||||
func TestToolContract(t *testing.T) {
|
||||
const (
|
||||
wantDomains = 18
|
||||
wantTools = 54
|
||||
wantRead = 33
|
||||
wantWrite = 21
|
||||
)
|
||||
|
||||
contracts := make([]toolContract, 0, wantTools)
|
||||
seenScopes := make(map[string]struct{}, wantDomains)
|
||||
seenNames := make(map[string]struct{}, wantTools)
|
||||
readCount, writeCount := 0, 0
|
||||
|
||||
for _, domain := range domainTools {
|
||||
scope := domain.Scope()
|
||||
if scope == "" {
|
||||
t.Fatal("registered tool domain has an empty scope")
|
||||
}
|
||||
if _, duplicate := seenScopes[scope]; duplicate {
|
||||
t.Fatalf("duplicate tool domain scope %q", scope)
|
||||
}
|
||||
seenScopes[scope] = struct{}{}
|
||||
|
||||
for _, registered := range domain.ReadTools() {
|
||||
contracts = append(contracts, decodeToolContract(t, scope, "read", registered.Tool))
|
||||
readCount++
|
||||
}
|
||||
for _, registered := range domain.WriteTools() {
|
||||
contracts = append(contracts, decodeToolContract(t, scope, "write", registered.Tool))
|
||||
writeCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(seenScopes) != wantDomains {
|
||||
t.Errorf("domain count = %d, want %d", len(seenScopes), wantDomains)
|
||||
}
|
||||
if len(contracts) != wantTools {
|
||||
t.Errorf("tool count = %d, want %d", len(contracts), wantTools)
|
||||
}
|
||||
if readCount != wantRead {
|
||||
t.Errorf("read tool count = %d, want %d", readCount, wantRead)
|
||||
}
|
||||
if writeCount != wantWrite {
|
||||
t.Errorf("write tool count = %d, want %d", writeCount, wantWrite)
|
||||
}
|
||||
|
||||
for _, contract := range contracts {
|
||||
if _, duplicate := seenNames[contract.Name]; duplicate {
|
||||
t.Errorf("duplicate tool name %q", contract.Name)
|
||||
}
|
||||
seenNames[contract.Name] = struct{}{}
|
||||
}
|
||||
|
||||
sort.Slice(contracts, func(i, j int) bool {
|
||||
if contracts[i].Scope != contracts[j].Scope {
|
||||
return contracts[i].Scope < contracts[j].Scope
|
||||
}
|
||||
if contracts[i].Access != contracts[j].Access {
|
||||
return contracts[i].Access < contracts[j].Access
|
||||
}
|
||||
return contracts[i].Name < contracts[j].Name
|
||||
})
|
||||
|
||||
got, err := json.MarshalIndent(contracts, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool contract: %v", err)
|
||||
}
|
||||
got = append(got, '\n')
|
||||
|
||||
goldenPath := filepath.Join("testdata", "tools.golden.json")
|
||||
if os.Getenv(updateToolContractEnv) == "1" {
|
||||
if err := os.WriteFile(goldenPath, got, 0o644); err != nil {
|
||||
t.Fatalf("update tool contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(goldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read tool contract: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("tool contract changed; inspect the semantic diff before running %s=1 go test -run '^TestToolContract$' ./operation/", updateToolContractEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeToolContract(t *testing.T, scope, access string, toolDefinition any) toolContract {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(toolDefinition)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s tool in scope %q: %v", access, scope, err)
|
||||
}
|
||||
var definition map[string]any
|
||||
if err := json.Unmarshal(data, &definition); err != nil {
|
||||
t.Fatalf("decode %s tool in scope %q: %v", access, scope, err)
|
||||
}
|
||||
|
||||
name := requiredString(t, definition, "name", scope)
|
||||
description := requiredString(t, definition, "description", name)
|
||||
inputSchema, ok := definition["inputSchema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool %q has inputSchema of type %T, want JSON object", name, definition["inputSchema"])
|
||||
}
|
||||
// An omitted required keyword and an empty array have the same JSON Schema meaning.
|
||||
if _, ok := inputSchema["required"]; !ok {
|
||||
inputSchema["required"] = []any{}
|
||||
}
|
||||
annotations, _ := definition["annotations"].(map[string]any)
|
||||
|
||||
// Normalize protocol defaults independently of SDK omitempty behavior.
|
||||
return toolContract{
|
||||
Scope: scope,
|
||||
Access: access,
|
||||
Name: name,
|
||||
Description: description,
|
||||
InputSchema: inputSchema,
|
||||
Annotations: contractAnnotations{
|
||||
Title: stringField(annotations, "title", ""),
|
||||
ReadOnlyHint: boolField(annotations, "readOnlyHint", false),
|
||||
DestructiveHint: boolField(annotations, "destructiveHint", true),
|
||||
IdempotentHint: boolField(annotations, "idempotentHint", false),
|
||||
OpenWorldHint: boolField(annotations, "openWorldHint", true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func requiredString(t *testing.T, object map[string]any, key, owner string) string {
|
||||
t.Helper()
|
||||
|
||||
value, ok := object[key].(string)
|
||||
if !ok || value == "" {
|
||||
t.Fatalf("%s has missing or empty %q", owner, key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func stringField(object map[string]any, key, fallback string) string {
|
||||
if value, ok := object[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolField(object map[string]any, key string, fallback bool) bool {
|
||||
if value, ok := object[key].(bool); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user