add MCP 2025-06-18 server packages

This commit is contained in:
AJ ONeal 2026-09-11 16:06:22 -06:00
parent 04aaadff75
commit 14cc617575
No known key found for this signature in database
9 changed files with 1516 additions and 0 deletions

3
mcp/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/mcp
go 1.26.0

42
mcp/server20250618/doc.go Normal file
View 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
View 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)
}

View 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, &params); 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, &params); 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, &params); 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
}

View 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)
}
}

View 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
View 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
View 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

View 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)
}
}