mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
470 lines
13 KiB
Go
470 lines
13 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
mcptypes "github.com/therootcompany/golib/mcp/types20250618"
|
|
)
|
|
|
|
const ProtocolVersion = "2025-06-18"
|
|
|
|
type ToolHandlerFunc func(ctx context.Context, request mcptypes.CallToolRequest) (*mcptypes.CallToolResult, error)
|
|
type ResourceHandlerFunc func(ctx context.Context, request mcptypes.ReadResourceRequest) ([]mcptypes.ResourceContents, error)
|
|
|
|
// MCPServer manages tool and resource registrations and dispatches JSON-RPC
|
|
// requests. It implements http.Handler.
|
|
type MCPServer struct {
|
|
Name string
|
|
Title string
|
|
Version string
|
|
Instructions string
|
|
Capabilities mcptypes.ServerCapabilities
|
|
AllowedOrigins []string
|
|
|
|
mu sync.RWMutex
|
|
tools []registeredTool
|
|
resources []registeredResource
|
|
methods map[string]MethodHandlerFunc // overrides for JSON-RPC methods
|
|
}
|
|
|
|
type registeredTool struct {
|
|
tool mcptypes.Tool
|
|
handler ToolHandlerFunc
|
|
}
|
|
|
|
type registeredResource struct {
|
|
resource mcptypes.Resource
|
|
handler ResourceHandlerFunc
|
|
}
|
|
|
|
type toolsListResult struct {
|
|
Tools []mcptypes.Tool `json:"tools"`
|
|
NextCursor string `json:"nextCursor,omitempty"`
|
|
}
|
|
|
|
type resourcesListResult struct {
|
|
Resources []mcptypes.Resource `json:"resources"`
|
|
NextCursor string `json:"nextCursor,omitempty"`
|
|
}
|
|
|
|
type resourcesReadResult struct {
|
|
Contents []mcptypes.ResourceContents `json:"contents"`
|
|
}
|
|
|
|
// HandleToolFunc registers a tool and its handler. Panics on duplicate names.
|
|
func (s *MCPServer) HandleToolFunc(name string, tool mcptypes.Tool, handler ToolHandlerFunc) {
|
|
tool.Name = name
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, rt := range s.tools {
|
|
if rt.tool.Name == name {
|
|
panic(fmt.Sprintf("mcp: duplicate tool registration: %q", name))
|
|
}
|
|
}
|
|
s.tools = append(s.tools, registeredTool{tool: tool, handler: handler})
|
|
}
|
|
|
|
func (s *MCPServer) HandleResource(uri string, resource mcptypes.Resource, handler ResourceHandlerFunc) {
|
|
if err := validateResourceURI(uri); err != nil {
|
|
panic(fmt.Sprintf("mcp: invalid resource URI %q: %v", uri, err))
|
|
}
|
|
resource.URI = uri
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, rr := range s.resources {
|
|
if rr.resource.URI == uri {
|
|
panic(fmt.Sprintf("mcp: duplicate resource registration: %q", uri))
|
|
}
|
|
}
|
|
s.resources = append(s.resources, registeredResource{resource: resource, handler: handler})
|
|
}
|
|
|
|
// HandleMethod registers a method override (used by Mux for tools/list, etc.).
|
|
func (s *MCPServer) HandleMethod(method string, handler MethodHandlerFunc) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.methods == nil {
|
|
s.methods = make(map[string]MethodHandlerFunc)
|
|
}
|
|
if _, exists := s.methods[method]; exists {
|
|
panic(fmt.Sprintf("mcp: duplicate method registration: %q", method))
|
|
}
|
|
s.methods[method] = handler
|
|
}
|
|
|
|
func (s *MCPServer) HandleRequest(ctx context.Context, raw json.RawMessage) any {
|
|
var req mcptypes.JSONRPCRequest
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
return mcptypes.JSONRPCError{
|
|
JSONRPC: mcptypes.JSONRPCVersion,
|
|
ID: nil,
|
|
Error: mcptypes.JSONRPCErrorDetails{Code: mcptypes.ErrCodeParse, Message: "Parse error"},
|
|
}
|
|
}
|
|
|
|
var envelope map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
return s.rpcError(nil, mcptypes.ErrCodeInvalidRequest, "Invalid JSON-RPC request")
|
|
}
|
|
if _, hasMethod := envelope["method"]; !hasMethod {
|
|
_, hasResult := envelope["result"]
|
|
_, hasError := envelope["error"]
|
|
if (hasResult || hasError) && req.JSONRPC == mcptypes.JSONRPCVersion && req.IDPresent && req.ID != nil && !req.ID.IsNull() && hasResult != hasError {
|
|
// HTTP transport accepts client responses but has no server-side request
|
|
// correlation to perform here.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if req.JSONRPC != mcptypes.JSONRPCVersion || req.Method == "" || (req.IDPresent && (req.ID == nil || req.ID.IsNull())) {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidRequest, "Invalid JSON-RPC request")
|
|
}
|
|
|
|
// Notifications (no id) are acknowledged without response.
|
|
if req.IsNotification() {
|
|
return nil
|
|
}
|
|
|
|
s.mu.RLock()
|
|
mh := s.methods[req.Method]
|
|
s.mu.RUnlock()
|
|
if mh != nil {
|
|
return mh(ctx, &req)
|
|
}
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
return s.handleInitialize(ctx, &req)
|
|
case "tools/list":
|
|
return s.handleToolsList(ctx, &req)
|
|
case "tools/call":
|
|
return s.handleToolsCall(ctx, &req)
|
|
case "resources/list":
|
|
return s.handleResourcesList(ctx, &req)
|
|
case "resources/read":
|
|
return s.handleResourcesRead(ctx, &req)
|
|
default:
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeMethodNotFound, fmt.Sprintf("Method not found: %s", req.Method))
|
|
}
|
|
}
|
|
|
|
func (s *MCPServer) handleInitialize(_ context.Context, req *mcptypes.JSONRPCRequest) any {
|
|
var params mcptypes.InitializeParams
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil || json.Unmarshal(req.Params, &fields) != nil || params.ProtocolVersion == "" || params.ClientInfo.Name == "" || params.ClientInfo.Version == "" {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, "Invalid params for initialize")
|
|
}
|
|
if _, ok := fields["capabilities"]; !ok {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, "Invalid params for initialize")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
capabilities := s.Capabilities
|
|
if capabilities.Tools == nil && len(s.tools) > 0 {
|
|
capabilities.Tools = &mcptypes.ToolsCapability{}
|
|
}
|
|
if capabilities.Resources == nil && len(s.resources) > 0 {
|
|
capabilities.Resources = &mcptypes.ResourcesCapability{}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
result := mcptypes.InitializeResult{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Capabilities: capabilities,
|
|
ServerInfo: mcptypes.ServerInfo{
|
|
Name: s.Name,
|
|
Title: s.Title,
|
|
Version: s.Version,
|
|
},
|
|
Instructions: s.Instructions,
|
|
}
|
|
return s.rpcResult(req.ID, result)
|
|
}
|
|
|
|
func (s *MCPServer) handleToolsList(_ context.Context, req *mcptypes.JSONRPCRequest) any {
|
|
s.mu.RLock()
|
|
tools := make([]mcptypes.Tool, 0, len(s.tools))
|
|
for _, rt := range s.tools {
|
|
if isAppOnly(rt.tool) {
|
|
continue
|
|
}
|
|
tools = append(tools, rt.tool)
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
return s.rpcResult(req.ID, toolsListResult{Tools: tools})
|
|
}
|
|
|
|
// isAppOnly reports whether the tool's visibility is restricted to "app" only.
|
|
// Tools with visibility ["app"] are hidden from tools/list (they're only
|
|
// invoked by the embedded MCP App UI, not the LLM).
|
|
func isAppOnly(t mcptypes.Tool) bool {
|
|
if t.Meta == nil || t.Meta.UI == nil || len(t.Meta.UI.Visibility) == 0 {
|
|
return false
|
|
}
|
|
for _, v := range t.Meta.UI.Visibility {
|
|
if v != "app" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *MCPServer) handleToolsCall(ctx context.Context, req *mcptypes.JSONRPCRequest) any {
|
|
var params mcptypes.CallToolParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, "Invalid params for tools/call")
|
|
}
|
|
|
|
if params.Arguments == nil {
|
|
params.Arguments = make(map[string]any)
|
|
}
|
|
|
|
s.mu.RLock()
|
|
var handler ToolHandlerFunc
|
|
for _, rt := range s.tools {
|
|
if rt.tool.Name == params.Name {
|
|
handler = rt.handler
|
|
break
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
if handler == nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, fmt.Sprintf("Unknown tool: %s", params.Name))
|
|
}
|
|
|
|
callReq := mcptypes.CallToolRequest{
|
|
Params: params,
|
|
}
|
|
|
|
result, err := s.callToolWithRecovery(ctx, handler, callReq)
|
|
|
|
if err != nil {
|
|
log.Printf("[mcp] tool %q error: %v", params.Name, err)
|
|
return s.rpcResult(req.ID, mcptypes.NewToolResultError("Tool execution failed"))
|
|
}
|
|
if result == nil {
|
|
log.Printf("[mcp] tool %q returned a nil result", params.Name)
|
|
return s.rpcResult(req.ID, mcptypes.NewToolResultError("Tool execution failed"))
|
|
}
|
|
return s.rpcResult(req.ID, result)
|
|
}
|
|
|
|
func (s *MCPServer) callToolWithRecovery(ctx context.Context, handler ToolHandlerFunc, req mcptypes.CallToolRequest) (result *mcptypes.CallToolResult, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[mcp] panic in tool %q: %v\n%s", req.Params.Name, r, debug.Stack())
|
|
result = &mcptypes.CallToolResult{
|
|
Content: []mcptypes.Content{mcptypes.TextContent{Type: "text", Text: fmt.Sprintf("Internal error: %v", r)}},
|
|
IsError: true,
|
|
}
|
|
err = nil
|
|
}
|
|
}()
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
func (s *MCPServer) handleResourcesList(_ context.Context, req *mcptypes.JSONRPCRequest) any {
|
|
s.mu.RLock()
|
|
resources := make([]mcptypes.Resource, len(s.resources))
|
|
for i, rr := range s.resources {
|
|
resources[i] = rr.resource
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
return s.rpcResult(req.ID, resourcesListResult{Resources: resources})
|
|
}
|
|
|
|
func (s *MCPServer) handleResourcesRead(ctx context.Context, req *mcptypes.JSONRPCRequest) any {
|
|
var params mcptypes.ReadResourceParams
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.URI == "" {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, "Invalid params for resources/read")
|
|
}
|
|
if err := validateResourceURI(params.URI); err != nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInvalidParams, "Invalid resource URI")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
var handler ResourceHandlerFunc
|
|
for _, rr := range s.resources {
|
|
if rr.resource.URI == params.URI {
|
|
handler = rr.handler
|
|
break
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
if handler == nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeResourceNotFound, fmt.Sprintf("Unknown resource: %s", params.URI))
|
|
}
|
|
|
|
readReq := mcptypes.ReadResourceRequest{
|
|
Params: params,
|
|
}
|
|
|
|
contents, err := handler(ctx, readReq)
|
|
if err != nil {
|
|
log.Printf("[mcp] resource %q error: %v", params.URI, err)
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInternal, "Resource read failed")
|
|
}
|
|
for _, content := range contents {
|
|
if content.Blob != "" {
|
|
if _, err := base64.StdEncoding.DecodeString(content.Blob); err != nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInternal, "Resource returned invalid binary data")
|
|
}
|
|
}
|
|
if err := validateResourceURI(content.URI); err != nil {
|
|
return s.rpcError(req.ID, mcptypes.ErrCodeInternal, "Resource returned invalid URI")
|
|
}
|
|
}
|
|
|
|
return s.rpcResult(req.ID, resourcesReadResult{Contents: contents})
|
|
}
|
|
|
|
func (s *MCPServer) rpcResult(id *mcptypes.RequestID, result any) mcptypes.JSONRPCResponse {
|
|
return mcptypes.JSONRPCResponse{
|
|
JSONRPC: mcptypes.JSONRPCVersion,
|
|
ID: id,
|
|
Result: result,
|
|
}
|
|
}
|
|
|
|
func (s *MCPServer) rpcError(id *mcptypes.RequestID, code int, message string) mcptypes.JSONRPCError {
|
|
return mcptypes.JSONRPCError{
|
|
JSONRPC: mcptypes.JSONRPCVersion,
|
|
ID: id,
|
|
Error: mcptypes.JSONRPCErrorDetails{Code: code, Message: message},
|
|
}
|
|
}
|
|
|
|
func validateResourceURI(raw string) error {
|
|
if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) >= 0 {
|
|
return fmt.Errorf("URI is empty or contains invalid characters")
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme == "" || u.Fragment != "" {
|
|
return fmt.Errorf("URI must be absolute and must not contain a fragment")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const maxBodySize = 10 << 20 // 10 MB
|
|
|
|
func (s *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" && !s.originAllowed(origin) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := validateProtocolHeader(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !acceptsJSONOrSSE(r.Header.Get("Accept")) {
|
|
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
|
|
return
|
|
}
|
|
s.handlePost(w, r)
|
|
}
|
|
|
|
func (s *MCPServer) originAllowed(origin string) bool {
|
|
return slices.Contains(s.AllowedOrigins, origin)
|
|
}
|
|
|
|
func validateProtocolHeader(r *http.Request) error {
|
|
version := r.Header.Get("MCP-Protocol-Version")
|
|
if version != "" && version != ProtocolVersion && version != "2025-03-26" {
|
|
return fmt.Errorf("unsupported MCP protocol version: %s", version)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func acceptsJSONOrSSE(accept string) bool {
|
|
if accept == "" {
|
|
return false
|
|
}
|
|
var jsonOK, sseOK bool
|
|
for value := range strings.SplitSeq(accept, ",") {
|
|
mediaType := strings.TrimSpace(strings.SplitN(value, ";", 2)[0])
|
|
switch mediaType {
|
|
case "application/json":
|
|
jsonOK = true
|
|
case "text/event-stream":
|
|
sseOK = true
|
|
}
|
|
}
|
|
return jsonOK && sseOK
|
|
}
|
|
|
|
func (s *MCPServer) handlePost(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(body) > maxBodySize {
|
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
|
|
body = trimSpace(body)
|
|
if len(body) == 0 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if body[0] == '[' || !json.Valid(body) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
result := s.HandleRequest(ctx, body)
|
|
if result == nil {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("[mcp] failed to encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeJSONError(w http.ResponseWriter, id *mcptypes.RequestID, code int, message string) {
|
|
writeJSON(w, http.StatusOK, mcptypes.JSONRPCError{
|
|
JSONRPC: mcptypes.JSONRPCVersion,
|
|
ID: id,
|
|
Error: mcptypes.JSONRPCErrorDetails{Code: code, Message: message},
|
|
})
|
|
}
|
|
|
|
func trimSpace(b []byte) []byte {
|
|
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t' || b[0] == '\n' || b[0] == '\r') {
|
|
b = b[1:]
|
|
}
|
|
return b
|
|
}
|