mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-03 15:49:23 +02:00
cc0cb109a8
Co-Authored-By: OpenAI Codex (GPT-5) <noreply@openai.com>
193 lines
5.4 KiB
Go
193 lines
5.4 KiB
Go
package tool
|
|
|
|
import (
|
|
"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) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
panicErr := fmt.Errorf("panic recovered in %s tool handler: %v", s.Tool.Name, recovered)
|
|
log.Errorf("%s", panicErr)
|
|
err = internalError(panicErr)
|
|
}
|
|
}()
|
|
|
|
arguments, err := decodeArguments(req.Params.Arguments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result, err = s.Handler(ctx, arguments)
|
|
if err != nil {
|
|
var protocolErr *jsonrpc.Error
|
|
if errors.As(err, &protocolErr) {
|
|
return nil, err
|
|
}
|
|
// Preserve mcp-go behavior; tool-result errors are a separate change.
|
|
return nil, internalError(err)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func decodeArguments(raw json.RawMessage) (map[string]any, error) {
|
|
// An omitted and a null "arguments" both mean the tool was called without any.
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return map[string]any{}, nil
|
|
}
|
|
|
|
var arguments map[string]any
|
|
if err := json.Unmarshal(raw, &arguments); err != nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: fmt.Sprintf("invalid tool arguments: %v", err),
|
|
}
|
|
}
|
|
return arguments, nil
|
|
}
|
|
|
|
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, ", "))
|
|
})
|
|
}
|