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:
Bo-Yi Wu
2026-08-02 21:30:19 +08:00
parent 290d06b40b
commit 80c8b25d6e
49 changed files with 5277 additions and 1528 deletions
+179
View File
@@ -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
}