mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
add MCP 2025-06-18 server packages
This commit is contained in:
parent
04aaadff75
commit
14cc617575
3
mcp/go.mod
Normal file
3
mcp/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module github.com/therootcompany/golib/mcp
|
||||
|
||||
go 1.26.0
|
||||
42
mcp/server20250618/doc.go
Normal file
42
mcp/server20250618/doc.go
Normal file
@ -0,0 +1,42 @@
|
||||
// Package mcpserver implements an MCP (Model Context Protocol) JSON-RPC server
|
||||
// with a routing layer modeled after Go's HTTP middleware pattern.
|
||||
//
|
||||
// # Routing
|
||||
//
|
||||
// The routing table can live alongside the HTTP mux, so every tool, resource,
|
||||
// and auth requirement is visible in one place:
|
||||
//
|
||||
// srv := &mcpserver.MCPServer{Name: "example", Version: "1"}
|
||||
// mux := mcpserver.NewMux(srv, authMiddleware)
|
||||
// mux.Register("tools/example", mcpserver.ToolHandler{Tool: tool, Handle: handler})
|
||||
// mux.Method("tools/list", mux.ToolsIndex())
|
||||
// resources := mux.Resources()
|
||||
// resources.Expose("example://item", mcpserver.ResourceHandler{Resource: resource, Handle: read})
|
||||
// http.Handle("POST /mcp", srv)
|
||||
//
|
||||
// Three verbs for three concepts:
|
||||
// - [Mux.Register] — register tools on the server (tools/call dispatch)
|
||||
// - [Mux.Method] — override a JSON-RPC method handler (tools/list, resources/list)
|
||||
// - [ResourceMux.Expose] — expose resources for clients to read (resources/read dispatch)
|
||||
//
|
||||
// # Handler types
|
||||
//
|
||||
// - [ToolHandler] — tool definition + handler func. Registered with a
|
||||
// "tools/" prefix that is stripped before reaching the wire.
|
||||
// - [ResourceHandler] — resource definition + handler func. Exposed
|
||||
// via [ResourceMux] keyed by URI.
|
||||
//
|
||||
// # Auth
|
||||
//
|
||||
// Authentication is enforced at the HTTP boundary, not inside the MCP server.
|
||||
// The RequireBearer middleware (in the mcpserver package) verifies JWTs and
|
||||
// returns 401 + WWW-Authenticate for OAuth discovery. This is visible in
|
||||
// main.go's routing table alongside other HTTP middleware chains.
|
||||
//
|
||||
// # Wire format
|
||||
//
|
||||
// The "tools/" prefix is a routing table convention that makes registrations
|
||||
// self-documenting. It is stripped before registration on [MCPServer] —
|
||||
// tool names on the wire contain no prefix. Resource URIs are passed
|
||||
// through as-is.
|
||||
package mcpserver
|
||||
103
mcp/server20250618/mux.go
Normal file
103
mcp/server20250618/mux.go
Normal file
@ -0,0 +1,103 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcptypes "github.com/therootcompany/golib/mcp/types20250618"
|
||||
)
|
||||
|
||||
// ToolMiddleware wraps a tool handler, analogous to func(http.Handler) http.Handler.
|
||||
type ToolMiddleware func(ToolHandlerFunc) ToolHandlerFunc
|
||||
|
||||
// ToolHandler pairs a tool definition with its handler function.
|
||||
type ToolHandler struct {
|
||||
Tool mcptypes.Tool
|
||||
Handle ToolHandlerFunc
|
||||
}
|
||||
|
||||
// ResourceHandler pairs a resource definition with its read handler.
|
||||
type ResourceHandler struct {
|
||||
Resource mcptypes.Resource
|
||||
Handle ResourceHandlerFunc
|
||||
}
|
||||
|
||||
// MethodHandlerFunc handles a raw JSON-RPC request for a specific method.
|
||||
type MethodHandlerFunc func(ctx context.Context, req *mcptypes.JSONRPCRequest) any
|
||||
|
||||
// Mux composes middleware chains and registers tools on an MCPServer.
|
||||
// Mirrors golib/http/middleware.MiddlewareMux: NewMux → .With() → .Register().
|
||||
type Mux struct {
|
||||
server *MCPServer
|
||||
middlewares []ToolMiddleware
|
||||
}
|
||||
|
||||
func NewMux(srv *MCPServer, middlewares ...ToolMiddleware) *Mux {
|
||||
return &Mux{
|
||||
server: srv,
|
||||
middlewares: middlewares,
|
||||
}
|
||||
}
|
||||
|
||||
// With returns a new Mux with additional middlewares appended.
|
||||
func (m *Mux) With(middlewares ...ToolMiddleware) *Mux {
|
||||
combined := make([]ToolMiddleware, 0, len(m.middlewares)+len(middlewares))
|
||||
combined = append(combined, m.middlewares...)
|
||||
combined = append(combined, middlewares...)
|
||||
return &Mux{
|
||||
server: m.server,
|
||||
middlewares: combined,
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a tool. The method must start with "tools/" — the prefix
|
||||
// is stripped to produce the wire-format tool name. Panics on wiring errors.
|
||||
func (m *Mux) Register(method string, th ToolHandler) {
|
||||
if !strings.HasPrefix(method, "tools/") {
|
||||
panic("mcp: tool registered without tools/ prefix: " + method)
|
||||
}
|
||||
name := strings.TrimPrefix(method, "tools/")
|
||||
if th.Tool.Name != "" && th.Tool.Name != name {
|
||||
panic(fmt.Sprintf("mcp: tool name mismatch: Register(%q) but Tool.Name is %q", method, th.Tool.Name))
|
||||
}
|
||||
|
||||
handler := th.Handle
|
||||
for i := len(m.middlewares) - 1; i >= 0; i-- {
|
||||
handler = m.middlewares[i](handler)
|
||||
}
|
||||
m.server.HandleToolFunc(name, th.Tool, handler)
|
||||
}
|
||||
|
||||
// Method overrides a built-in JSON-RPC method (e.g. "tools/list").
|
||||
func (m *Mux) Method(method string, handler MethodHandlerFunc) {
|
||||
m.server.HandleMethod(method, handler)
|
||||
}
|
||||
|
||||
// Resources returns a ResourceMux for resources/read.
|
||||
func (m *Mux) Resources() *ResourceMux {
|
||||
return &ResourceMux{server: m.server}
|
||||
}
|
||||
|
||||
// ToolsIndex returns a handler for tools/list.
|
||||
func (m *Mux) ToolsIndex() MethodHandlerFunc {
|
||||
return m.server.handleToolsList
|
||||
}
|
||||
|
||||
// ResourcesIndex returns a handler for resources/list.
|
||||
func (m *Mux) ResourcesIndex() MethodHandlerFunc {
|
||||
return m.server.handleResourcesList
|
||||
}
|
||||
|
||||
// ResourceMux registers resources on the server. Created via [Mux.Resources].
|
||||
type ResourceMux struct {
|
||||
server *MCPServer
|
||||
}
|
||||
|
||||
// Expose registers a resource by URI. Panics if the handler's URI doesn't match.
|
||||
func (rm *ResourceMux) Expose(uri string, rh ResourceHandler) {
|
||||
if rh.Resource.URI != "" && rh.Resource.URI != uri {
|
||||
panic(fmt.Sprintf("mcp: resource URI mismatch: Expose(%q) but Resource.URI is %q", uri, rh.Resource.URI))
|
||||
}
|
||||
rm.server.HandleResource(uri, rh.Resource, rh.Handle)
|
||||
}
|
||||
469
mcp/server20250618/server.go
Normal file
469
mcp/server20250618/server.go
Normal file
@ -0,0 +1,469 @@
|
||||
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
|
||||
}
|
||||
137
mcp/server20250618/server_test.go
Normal file
137
mcp/server20250618/server_test.go
Normal file
@ -0,0 +1,137 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcptypes "github.com/therootcompany/golib/mcp/types20250618"
|
||||
)
|
||||
|
||||
func TestServeHTTPRejectsBatch(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader("[]"))
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
(&MCPServer{}).ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", res.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRequiresAcceptTypes(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
(&MCPServer{}).ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusNotAcceptable {
|
||||
t.Fatalf("status = %d, want %d", res.Code, http.StatusNotAcceptable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRejectsUntrustedOrigin(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
(&MCPServer{}).ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", res.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRejectsUnsupportedProtocolVersion(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("MCP-Protocol-Version", "2099-01-01")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
(&MCPServer{}).ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", res.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRejectsNullID(t *testing.T) {
|
||||
result := (&MCPServer{}).HandleRequest(nil, []byte(`{"jsonrpc":"2.0","id":null,"method":"tools/list"}`))
|
||||
|
||||
errResponse, ok := result.(mcptypes.JSONRPCError)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want mcptypes.JSONRPCError", result)
|
||||
}
|
||||
if errResponse.Error.Code != mcptypes.ErrCodeInvalidRequest {
|
||||
t.Fatalf("error code = %d, want %d", errResponse.Error.Code, mcptypes.ErrCodeInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestAcceptsJSONRPCResponse(t *testing.T) {
|
||||
result := (&MCPServer{}).HandleRequest(nil, []byte(`{"jsonrpc":"2.0","id":1,"result":{}}`))
|
||||
if result != nil {
|
||||
t.Fatalf("result = %#v, want nil for accepted response", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsCallLeavesArgumentValidationToHandler(t *testing.T) {
|
||||
called := false
|
||||
srv := &MCPServer{}
|
||||
srv.HandleToolFunc("echo", mcptypes.Tool{
|
||||
InputSchema: mcptypes.ToolInputSchema{
|
||||
Type: "object",
|
||||
Required: []string{"message"},
|
||||
Properties: map[string]mcptypes.Property{
|
||||
"message": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}, func(context.Context, mcptypes.CallToolRequest) (*mcptypes.CallToolResult, error) {
|
||||
called = true
|
||||
return mcptypes.NewToolResultText("ok"), nil
|
||||
})
|
||||
|
||||
result := srv.HandleRequest(nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{}}}`))
|
||||
if _, ok := result.(mcptypes.JSONRPCResponse); !ok {
|
||||
t.Fatalf("result = %#v, want successful tool response", result)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("tool was not called; argument validation belongs to the handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceReadRejectsInvalidBlob(t *testing.T) {
|
||||
srv := &MCPServer{}
|
||||
srv.HandleResource("test://item", mcptypes.Resource{Name: "item"}, func(context.Context, mcptypes.ReadResourceRequest) ([]mcptypes.ResourceContents, error) {
|
||||
return []mcptypes.ResourceContents{{URI: "test://item", Blob: "not base64"}}, nil
|
||||
})
|
||||
result := srv.HandleRequest(nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"test://item"}}`))
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "invalid binary data") {
|
||||
t.Fatalf("result = %s, want invalid binary error", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeNegotiatesCurrentVersion(t *testing.T) {
|
||||
srv := &MCPServer{Name: "test", Version: "1"}
|
||||
result := srv.HandleRequest(nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"client","version":"1"}}}`))
|
||||
|
||||
response, ok := result.(mcptypes.JSONRPCResponse)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want mcptypes.JSONRPCResponse", result)
|
||||
}
|
||||
initialize, ok := response.Result.(mcptypes.InitializeResult)
|
||||
if !ok {
|
||||
t.Fatalf("result payload type = %T, want mcptypes.InitializeResult", response.Result)
|
||||
}
|
||||
if initialize.ProtocolVersion != ProtocolVersion {
|
||||
t.Fatalf("protocol version = %q, want %q", initialize.ProtocolVersion, ProtocolVersion)
|
||||
}
|
||||
}
|
||||
76
mcp/types20250618/resource.go
Normal file
76
mcp/types20250618/resource.go
Normal file
@ -0,0 +1,76 @@
|
||||
package mcptypes
|
||||
|
||||
import "context"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resource describes an MCP resource.
|
||||
//
|
||||
// Differences from mcp-go:
|
||||
// - Omits: Annotated embed, Icons, Size fields. Add when needed.
|
||||
type Resource struct {
|
||||
Meta *Meta `json:"_meta,omitempty"`
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// Annotations provide optional hints about a resource or content item.
|
||||
type Annotations struct {
|
||||
Audience []string `json:"audience,omitempty"`
|
||||
Priority *float64 `json:"priority,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceTemplate describes a parameterized resource URI.
|
||||
type ResourceTemplate struct {
|
||||
Meta *Meta `json:"_meta,omitempty"`
|
||||
URITemplate string `json:"uriTemplate"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource contents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ResourceContents represents the contents of a resource.
|
||||
// Meta may carry _meta.ui (CSP, permissions) per the MCP Apps spec;
|
||||
// content-item _meta takes precedence over resource-listing _meta.
|
||||
type ResourceContents struct {
|
||||
Meta *Meta `json:"_meta,omitempty"`
|
||||
URI string `json:"uri"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blob string `json:"blob,omitempty"` // base64-encoded
|
||||
}
|
||||
|
||||
// TextResourceContents represents text resource contents.
|
||||
type TextResourceContents = ResourceContents
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ReadResourceRequest is the request for reading a resource.
|
||||
type ReadResourceRequest struct {
|
||||
Method string
|
||||
Params ReadResourceParams
|
||||
}
|
||||
|
||||
// ReadResourceParams contains the resource read parameters.
|
||||
type ReadResourceParams struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// ResourceHandler is the function signature for resource read handlers.
|
||||
type ResourceHandler func(ctx context.Context, req ReadResourceRequest) ([]ResourceContents, error)
|
||||
269
mcp/types20250618/tool.go
Normal file
269
mcp/types20250618/tool.go
Normal file
@ -0,0 +1,269 @@
|
||||
package mcptypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Tool describes an MCP tool with its name, description, input schema, and hints.
|
||||
//
|
||||
// Differences from mcp-go:
|
||||
// - Annotations is a pointer (omitted when nil); mcp-go uses a value (always serialized).
|
||||
// - Omits: RawInputSchema, OutputSchema, RawOutputSchema, DeferLoading, Icons, Execution.
|
||||
// These are unused; add them when needed.
|
||||
//
|
||||
// We intentionally reject complex runtime schema validation here. The schema
|
||||
// is typed instead, and handlers validate and decode their arguments using Go
|
||||
// types and the accessors on CallToolRequest.
|
||||
type Tool struct {
|
||||
Meta *Meta `json:"_meta,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema ToolInputSchema `json:"inputSchema"`
|
||||
Annotations *ToolAnnotation `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// Property describes a single property in a tool's input schema.
|
||||
type Property struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Items *ToolInputSchema `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ToolInputSchema is the JSON Schema advertised for tool input. It covers the
|
||||
// simple typed schemas supported by this package; handlers enforce semantics.
|
||||
type ToolInputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]Property `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// ToolAnnotation contains behavioral hints for clients.
|
||||
type ToolAnnotation struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
|
||||
DestructiveHint *bool `json:"destructiveHint,omitempty"`
|
||||
IdempotentHint *bool `json:"idempotentHint,omitempty"`
|
||||
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CallToolRequest is the request object passed to tool handlers.
|
||||
type CallToolRequest struct {
|
||||
Method string
|
||||
Params CallToolParams
|
||||
}
|
||||
|
||||
// CallToolParams contains the tool call parameters.
|
||||
type CallToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// GetArguments returns the raw arguments map.
|
||||
func (r CallToolRequest) GetArguments() map[string]any {
|
||||
return r.Params.Arguments
|
||||
}
|
||||
|
||||
// GetString returns a string argument or the default value.
|
||||
func (r CallToolRequest) GetString(key, defaultValue string) string {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetInt returns an integer argument or the default value.
|
||||
func (r CallToolRequest) GetInt(key string, defaultValue int) int {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return int(i)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// GetFloat returns a float argument or the default value.
|
||||
func (r CallToolRequest) GetFloat(key string, defaultValue float64) float64 {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return f
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// GetBool returns a boolean argument or the default value.
|
||||
func (r CallToolRequest) GetBool(key string, defaultValue bool) bool {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RequireString returns the string argument or an error if missing.
|
||||
func (r CallToolRequest) RequireString(key string) (string, error) {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required argument: %s", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument %s must be a string", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RequireInt returns the integer argument or an error if missing.
|
||||
func (r CallToolRequest) RequireInt(key string) (int, error) {
|
||||
v, ok := r.Params.Arguments[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing required argument: %s", key)
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), nil
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("argument %s must be an integer", key)
|
||||
}
|
||||
return int(i), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("argument %s must be a number", key)
|
||||
}
|
||||
}
|
||||
|
||||
// BindArguments unmarshals arguments into the target struct.
|
||||
func (r CallToolRequest) BindArguments(target any) error {
|
||||
data, err := json.Marshal(r.Params.Arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CallToolResult is the response from a tool handler.
|
||||
type CallToolResult struct {
|
||||
Meta *Meta `json:"-"` // marshaled manually; see MarshalJSON
|
||||
Content []Content `json:"-"`
|
||||
StructuredContent any `json:"-"`
|
||||
IsError bool `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes only non-empty fields and omits _meta when it carries no
|
||||
// data — matching the wire format produced by mcp-go.
|
||||
func (r CallToolResult) MarshalJSON() ([]byte, error) {
|
||||
m := make(map[string]any, 4)
|
||||
if r.Meta != nil && !r.Meta.IsEmpty() {
|
||||
m["_meta"] = r.Meta
|
||||
}
|
||||
m["content"] = r.Content
|
||||
if r.StructuredContent != nil {
|
||||
m["structuredContent"] = r.StructuredContent
|
||||
}
|
||||
if r.IsError {
|
||||
m["isError"] = r.IsError
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// NewToolResultText creates a result with a single text content.
|
||||
func NewToolResultText(text string) *CallToolResult {
|
||||
return &CallToolResult{
|
||||
Content: []Content{TextContent{Type: "text", Text: text}},
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResultError creates an error result with text content.
|
||||
func NewToolResultError(message string) *CallToolResult {
|
||||
return &CallToolResult{
|
||||
Content: []Content{TextContent{Type: "text", Text: message}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResultJSON creates a result with JSON-serialized structured content and text.
|
||||
func NewToolResultJSON(data any) (*CallToolResult, error) {
|
||||
textBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CallToolResult{
|
||||
Content: []Content{TextContent{Type: "text", Text: string(textBytes)}},
|
||||
StructuredContent: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewToolResultStructured creates a result with both structured and text content.
|
||||
func NewToolResultStructured(structured any, text string) *CallToolResult {
|
||||
return &CallToolResult{
|
||||
Content: []Content{TextContent{Type: "text", Text: text}},
|
||||
StructuredContent: structured,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResultStructuredOnly creates a result with structured content and a
|
||||
// JSON text fallback for backward compatibility with clients that don't
|
||||
// support structuredContent.
|
||||
func NewToolResultStructuredOnly(structured any) *CallToolResult {
|
||||
var fallbackText string
|
||||
jsonBytes, err := json.Marshal(structured)
|
||||
if err != nil {
|
||||
fallbackText = fmt.Sprintf("Error serializing structured content: %v", err)
|
||||
} else {
|
||||
fallbackText = string(jsonBytes)
|
||||
}
|
||||
return &CallToolResult{
|
||||
Content: []Content{TextContent{Type: "text", Text: fallbackText}},
|
||||
StructuredContent: structured,
|
||||
}
|
||||
}
|
||||
344
mcp/types20250618/types.go
Normal file
344
mcp/types20250618/types.go
Normal file
@ -0,0 +1,344 @@
|
||||
// Package mcptypes defines the types for the Model Context Protocol (JSON-RPC 2.0).
|
||||
//
|
||||
// This is a minimal, self-contained implementation covering the subset of the
|
||||
// MCP spec we use: tools, resources, and the MCP Apps extension. Originally
|
||||
// derived from mcp-go (github.com/mark3labs/mcp-go) then simplified to struct
|
||||
// literals, typed schemas, and no builder patterns.
|
||||
package mcptypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-RPC 2.0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// JSONRPCVersion is the JSON-RPC protocol version.
|
||||
const JSONRPCVersion = "2.0"
|
||||
|
||||
// IDish is a JSON-RPC request ID. Its zero value is null.
|
||||
//
|
||||
// JSON-RPC permits string, number, and null IDs. json.Number preserves the
|
||||
// original numeric spelling and avoids losing precision through float64.
|
||||
type IDish struct {
|
||||
value any // string | json.Number | nil
|
||||
}
|
||||
|
||||
// NewInt creates an integer ID.
|
||||
func NewInt(value int64) IDish {
|
||||
return IDish{value: json.Number(fmt.Sprintf("%d", value))}
|
||||
}
|
||||
|
||||
// NewFloat creates a floating-point ID.
|
||||
func NewFloat(value float64) IDish {
|
||||
return IDish{value: json.Number(fmt.Sprintf("%g", value))}
|
||||
}
|
||||
|
||||
// NewString creates a string ID.
|
||||
func NewString(value string) IDish {
|
||||
return IDish{value: value}
|
||||
}
|
||||
|
||||
// NewNull creates a null ID. The zero value is equivalent.
|
||||
func NewNull() IDish { return IDish{} }
|
||||
|
||||
func (id IDish) MarshalJSON() ([]byte, error) {
|
||||
if id.value == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(id.value)
|
||||
}
|
||||
|
||||
func (id *IDish) UnmarshalJSON(data []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return fmt.Errorf("invalid JSON-RPC id: %s", data)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return fmt.Errorf("invalid JSON-RPC id: %s", data)
|
||||
}
|
||||
switch value := value.(type) {
|
||||
case nil, string, json.Number:
|
||||
id.value = value
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("JSON-RPC id must be a string, number, or null: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func (id IDish) String() string {
|
||||
switch value := id.value.(type) {
|
||||
case string:
|
||||
return value
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return "null"
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText encodes the ID as text. Null is encoded as "null".
|
||||
func (id IDish) MarshalText() ([]byte, error) {
|
||||
return []byte(id.String()), nil
|
||||
}
|
||||
|
||||
// MarshalCSV encodes null as an empty field and other IDs as their text form.
|
||||
func (id IDish) MarshalCSV() (string, error) {
|
||||
text, err := id.MarshalText()
|
||||
if id.value == nil {
|
||||
return "", err
|
||||
}
|
||||
return string(text), err
|
||||
}
|
||||
|
||||
// IsNull reports whether the ID explicitly contains JSON null.
|
||||
func (id IDish) IsNull() bool {
|
||||
return id.value == nil
|
||||
}
|
||||
|
||||
// RequestID is retained as an alias for callers using the original name.
|
||||
type RequestID = IDish
|
||||
|
||||
// JSONRPCRequest is an incoming JSON-RPC 2.0 request.
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *RequestID `json:"id,omitempty"` // nil for notifications
|
||||
IDPresent bool `json:"-"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Meta map[string]any `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON records whether the request contained an ID. JSON null and an
|
||||
// absent ID both decode to nil pointers, but only the latter is a notification.
|
||||
func (r *JSONRPCRequest) UnmarshalJSON(data []byte) error {
|
||||
type request JSONRPCRequest
|
||||
var decoded request
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
*r = JSONRPCRequest(decoded)
|
||||
_, r.IDPresent = fields["id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsNotification returns true if this is a JSON-RPC notification (no id).
|
||||
func (r *JSONRPCRequest) IsNotification() bool {
|
||||
return !r.IDPresent
|
||||
}
|
||||
|
||||
// JSONRPCResponse is a successful JSON-RPC 2.0 response.
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *RequestID `json:"id"`
|
||||
Result any `json:"result"`
|
||||
}
|
||||
|
||||
// JSONRPCError is a JSON-RPC 2.0 error response.
|
||||
type JSONRPCError struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *RequestID `json:"id"`
|
||||
Error JSONRPCErrorDetails `json:"error"`
|
||||
}
|
||||
|
||||
// JSONRPCErrorDetails contains the error code, message, and optional data.
|
||||
type JSONRPCErrorDetails struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Standard JSON-RPC error codes.
|
||||
const (
|
||||
ErrCodeParse = -32700
|
||||
ErrCodeInvalidRequest = -32600
|
||||
ErrCodeMethodNotFound = -32601
|
||||
ErrCodeInvalidParams = -32602
|
||||
ErrCodeInternal = -32603
|
||||
ErrCodeResourceNotFound = -32002
|
||||
)
|
||||
|
||||
// PaginatedRequest contains the optional opaque cursor used by list methods.
|
||||
type PaginatedRequest struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
// JSONRPCNotification is an outbound notification (no id).
|
||||
type JSONRPCNotification struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// InitializeResult is the typed response for the "initialize" method.
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities ServerCapabilities `json:"capabilities"`
|
||||
ServerInfo ServerInfo `json:"serverInfo"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
}
|
||||
|
||||
// InitializeParams contains the client's version, capabilities, and identity.
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities ClientCapabilities `json:"capabilities"`
|
||||
ClientInfo ClientInfo `json:"clientInfo"`
|
||||
}
|
||||
|
||||
// ClientCapabilities describes optional features offered by a client.
|
||||
type ClientCapabilities struct {
|
||||
Experimental map[string]map[string]any `json:"experimental,omitempty"`
|
||||
Roots *RootsCapability `json:"roots,omitempty"`
|
||||
Sampling map[string]any `json:"sampling,omitempty"`
|
||||
Elicitation map[string]any `json:"elicitation,omitempty"`
|
||||
}
|
||||
|
||||
type RootsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
// ServerCapabilities describes optional features offered by a server.
|
||||
type ServerCapabilities struct {
|
||||
Experimental map[string]map[string]any `json:"experimental,omitempty"`
|
||||
Logging map[string]any `json:"logging,omitempty"`
|
||||
Completions map[string]any `json:"completions,omitempty"`
|
||||
Prompts *PromptsCapability `json:"prompts,omitempty"`
|
||||
Resources *ResourcesCapability `json:"resources,omitempty"`
|
||||
Tools *ToolsCapability `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type PromptsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
type ResourcesCapability struct {
|
||||
Subscribe bool `json:"subscribe,omitempty"`
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
type ToolsCapability struct {
|
||||
ListChanged bool `json:"listChanged,omitempty"`
|
||||
}
|
||||
|
||||
// ClientInfo identifies the client implementation.
|
||||
type ClientInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ServerInfo identifies the server implementation.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AppUI is the _meta.ui object shared by tools and resources in the MCP Apps spec.
|
||||
// Tools set ResourceURI and Visibility; resources set CSP and Permissions.
|
||||
// All fields are optional; omitempty keeps the wire format clean.
|
||||
// See https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
|
||||
type AppUI struct {
|
||||
// Tool fields
|
||||
ResourceURI string `json:"resourceUri,omitempty"`
|
||||
Visibility []string `json:"visibility,omitempty"`
|
||||
|
||||
// Resource fields
|
||||
CSP *AppCSP `json:"csp,omitempty"`
|
||||
Permissions *AppPermissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// AppCSP controls which external origins the sandboxed app can load from.
|
||||
type AppCSP struct {
|
||||
ConnectDomains []string `json:"connectDomains,omitempty"`
|
||||
ResourceDomains []string `json:"resourceDomains,omitempty"`
|
||||
FrameDomains []string `json:"frameDomains,omitempty"`
|
||||
BaseURIDomains []string `json:"baseUriDomains,omitempty"`
|
||||
}
|
||||
|
||||
// AppPermissions declares iframe sandbox permissions as empty-object markers.
|
||||
type AppPermissions struct {
|
||||
ClipboardWrite *struct{} `json:"clipboardWrite,omitempty"`
|
||||
Camera *struct{} `json:"camera,omitempty"`
|
||||
Microphone *struct{} `json:"microphone,omitempty"`
|
||||
Geolocation *struct{} `json:"geolocation,omitempty"`
|
||||
}
|
||||
|
||||
// Meta carries additional metadata on tools, resources, and results.
|
||||
// Values holds non-MCP extension fields; UI is the typed MCP Apps field.
|
||||
type Meta struct {
|
||||
Values map[string]any `json:"-"`
|
||||
UI *AppUI `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves arbitrary extension metadata while encoding the typed
|
||||
// MCP Apps field under its reserved "ui" key.
|
||||
func (m Meta) MarshalJSON() ([]byte, error) {
|
||||
values := make(map[string]any, len(m.Values)+1)
|
||||
maps.Copy(values, m.Values)
|
||||
if m.UI != nil {
|
||||
values["ui"] = m.UI
|
||||
}
|
||||
return json.Marshal(values)
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes arbitrary metadata and the typed MCP Apps field.
|
||||
func (m *Meta) UnmarshalJSON(data []byte) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(data, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(values, "ui")
|
||||
m.Values = values
|
||||
var raw struct {
|
||||
UI *AppUI `json:"ui"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
m.UI = raw.UI
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEmpty returns true when Meta carries no data. Used by containers that
|
||||
// need to decide whether to omit the _meta key entirely.
|
||||
func (m *Meta) IsEmpty() bool {
|
||||
return m == nil || (m.UI == nil && len(m.Values) == 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Content is a union type for tool result content.
|
||||
// mcp-go uses separate TextContent, ImageContent, AudioContent structs each with
|
||||
// an Annotated embed and Meta field. We use a single flat struct since we only
|
||||
// produce text content; add typed variants when image/audio support is needed.
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// TextContent represents text content in a tool result.
|
||||
type TextContent = Content
|
||||
73
mcp/types20250618/types_test.go
Normal file
73
mcp/types20250618/types_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package mcptypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIDishRoundTripsJSONRPCIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
csv string
|
||||
}{
|
||||
{name: "null", json: "null", csv: ""},
|
||||
{name: "string", json: `"abc"`, csv: "abc"},
|
||||
{name: "integer", json: "9007199254740993", csv: "9007199254740993"},
|
||||
{name: "float", json: "1.25", csv: "1.25"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var id IDish
|
||||
if err := json.Unmarshal([]byte(tt.json), &id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != tt.json {
|
||||
t.Fatalf("JSON = %s, want %s", data, tt.json)
|
||||
}
|
||||
csv, err := id.MarshalCSV()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if csv != tt.csv {
|
||||
t.Fatalf("CSV = %q, want %q", csv, tt.csv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDishRejectsOtherJSONTypes(t *testing.T) {
|
||||
for _, input := range []string{`true`, `[]`, `{}`} {
|
||||
var id IDish
|
||||
if err := json.Unmarshal([]byte(input), &id); err == nil {
|
||||
t.Fatalf("Unmarshal(%s) succeeded, want error", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaRoundTripPreservesExtensions(t *testing.T) {
|
||||
original := Meta{
|
||||
Values: map[string]any{"example.com/trace": "abc"},
|
||||
UI: &AppUI{ResourceURI: "ui://example"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Meta
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Values["example.com/trace"] != "abc" {
|
||||
t.Fatalf("extension metadata was not preserved: %#v", decoded.Values)
|
||||
}
|
||||
if decoded.UI == nil || decoded.UI.ResourceURI != "ui://example" {
|
||||
t.Fatalf("UI metadata was not preserved: %#v", decoded.UI)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user