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