mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-03 15:49:23 +02:00
80c8b25d6e
- 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>
180 lines
5.2 KiB
Go
180 lines
5.2 KiB
Go
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
|
|
}
|