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>
209 lines
5.8 KiB
Go
209 lines
5.8 KiB
Go
package tool
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type Handler func(context.Context, map[string]any) (*mcp.CallToolResult, error)
|
|
|
|
type ServerTool struct {
|
|
Tool *mcp.Tool
|
|
Handler Handler
|
|
}
|
|
|
|
type Tool struct {
|
|
scope string
|
|
write []ServerTool
|
|
read []ServerTool
|
|
}
|
|
|
|
func New(scope string) *Tool {
|
|
return &Tool{
|
|
scope: scope,
|
|
write: make([]ServerTool, 0, 100),
|
|
read: make([]ServerTool, 0, 100),
|
|
}
|
|
}
|
|
|
|
// Scope returns the canonical scope name this domain of tools was registered under.
|
|
func (t *Tool) Scope() string {
|
|
return t.scope
|
|
}
|
|
|
|
func (t *Tool) RegisterWrite(s ServerTool) {
|
|
t.write = append(t.write, s)
|
|
}
|
|
|
|
func (t *Tool) RegisterRead(s ServerTool) {
|
|
t.read = append(t.read, s)
|
|
}
|
|
|
|
// ReadTools returns the read-only tools registered on this domain, ignoring
|
|
// the read-only and allowlist flags that Tools applies.
|
|
func (t *Tool) ReadTools() []ServerTool {
|
|
return t.read
|
|
}
|
|
|
|
// WriteTools returns the write tools registered on this domain, ignoring the
|
|
// read-only and allowlist flags that Tools applies.
|
|
func (t *Tool) WriteTools() []ServerTool {
|
|
return t.write
|
|
}
|
|
|
|
// Tools returns the tools registered on this domain after applying the
|
|
// read-only filter and the scope/tool allowlists (union semantics: a tool is
|
|
// kept if its domain's scope is in AllowedScopes OR its name is in
|
|
// AllowedTools). With no allowlists set, all tools pass through unchanged.
|
|
func (t *Tool) Tools() []ServerTool {
|
|
all := make([]ServerTool, 0, len(t.write)+len(t.read))
|
|
if !flag.ReadOnly {
|
|
all = append(all, t.write...)
|
|
}
|
|
all = append(all, t.read...)
|
|
if len(flag.AllowedScopes) == 0 && len(flag.AllowedTools) == 0 {
|
|
return all
|
|
}
|
|
_, scopeAllowed := flag.AllowedScopes[t.scope]
|
|
filtered := make([]ServerTool, 0, len(all))
|
|
for _, st := range all {
|
|
_, toolAllowed := flag.AllowedTools[st.Tool.Name]
|
|
if scopeAllowed || toolAllowed {
|
|
filtered = append(filtered, st)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// MCPHandler adapts a project handler to the official SDK's low-level handler.
|
|
func (s ServerTool) MCPHandler() mcp.ToolHandler {
|
|
return func(ctx context.Context, req *mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
|
|
name := ""
|
|
if s.Tool != nil {
|
|
name = s.Tool.Name
|
|
}
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
panicErr := fmt.Errorf("panic recovered in %s tool handler: %v", name, recovered)
|
|
log.Errorf("%s", panicErr)
|
|
result = nil
|
|
err = &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: panicErr.Error()}
|
|
}
|
|
}()
|
|
|
|
if req == nil || req.Params == nil {
|
|
return nil, invalidParamsError("missing tool call parameters")
|
|
}
|
|
|
|
arguments, err := decodeArguments(req.Params.Arguments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.Handler == nil {
|
|
return nil, internalError(fmt.Errorf("tool %q has no handler", name))
|
|
}
|
|
|
|
result, err = s.Handler(ctx, arguments)
|
|
if err != nil {
|
|
var protocolErr *jsonrpc.Error
|
|
if errors.As(err, &protocolErr) {
|
|
return nil, err
|
|
}
|
|
return nil, internalError(err)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func decodeArguments(raw json.RawMessage) (map[string]any, error) {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) == 0 {
|
|
return map[string]any{}, nil
|
|
}
|
|
if bytes.Equal(trimmed, []byte("null")) {
|
|
return nil, invalidParamsError("tool arguments must be an object")
|
|
}
|
|
|
|
var arguments map[string]any
|
|
if err := json.Unmarshal(trimmed, &arguments); err != nil {
|
|
return nil, invalidParamsError(fmt.Sprintf("invalid tool arguments: %v", err))
|
|
}
|
|
return arguments, nil
|
|
}
|
|
|
|
func invalidParamsError(message string) error {
|
|
return &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: message}
|
|
}
|
|
|
|
func internalError(err error) error {
|
|
return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: err.Error()}
|
|
}
|
|
|
|
// warnUnmatched logs the names present in allowlist but absent from known,
|
|
// via logUnmatched, so WarnUnmatchedAllowedTools and WarnUnmatchedAllowedScopes
|
|
// share the same "collect, sort, no-op when empty" logic and can't drift.
|
|
// No-op if allowlist is empty or every name in it is known.
|
|
func warnUnmatched(allowlist, known map[string]struct{}, logUnmatched func(unmatched []string)) {
|
|
if len(allowlist) == 0 {
|
|
return
|
|
}
|
|
var unmatched []string
|
|
for name := range allowlist {
|
|
if _, ok := known[name]; !ok {
|
|
unmatched = append(unmatched, name)
|
|
}
|
|
}
|
|
if len(unmatched) == 0 {
|
|
return
|
|
}
|
|
slices.Sort(unmatched)
|
|
logUnmatched(unmatched)
|
|
}
|
|
|
|
// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't
|
|
// match a tool registered on any of the given domains. No-op if the allowlist
|
|
// is empty.
|
|
func WarnUnmatchedAllowedTools(domains ...*Tool) {
|
|
known := map[string]struct{}{}
|
|
for _, d := range domains {
|
|
for _, st := range d.read {
|
|
known[st.Tool.Name] = struct{}{}
|
|
}
|
|
for _, st := range d.write {
|
|
known[st.Tool.Name] = struct{}{}
|
|
}
|
|
}
|
|
warnUnmatched(flag.AllowedTools, known, func(unmatched []string) {
|
|
log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", "))
|
|
})
|
|
}
|
|
|
|
// WarnUnmatchedAllowedScopes logs any names in flag.AllowedScopes that don't
|
|
// match the scope of any of the given domains. No-op if the allowlist is
|
|
// empty.
|
|
func WarnUnmatchedAllowedScopes(domains ...*Tool) {
|
|
knownSet := map[string]struct{}{}
|
|
known := make([]string, 0, len(domains))
|
|
for _, d := range domains {
|
|
if _, ok := knownSet[d.scope]; !ok {
|
|
knownSet[d.scope] = struct{}{}
|
|
known = append(known, d.scope)
|
|
}
|
|
}
|
|
warnUnmatched(flag.AllowedScopes, knownSet, func(unmatched []string) {
|
|
slices.Sort(known)
|
|
log.Warnf("Unknown scopes in --scope allowlist (ignored): %s. Valid scopes: %s", strings.Join(unmatched, ", "), strings.Join(known, ", "))
|
|
})
|
|
}
|