diff --git a/mcp/go.mod b/mcp/go.mod new file mode 100644 index 0000000..ae96224 --- /dev/null +++ b/mcp/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/mcp + +go 1.26.0 diff --git a/mcp/server20250618/doc.go b/mcp/server20250618/doc.go new file mode 100644 index 0000000..a4ffae7 --- /dev/null +++ b/mcp/server20250618/doc.go @@ -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 diff --git a/mcp/server20250618/mux.go b/mcp/server20250618/mux.go new file mode 100644 index 0000000..13c5e27 --- /dev/null +++ b/mcp/server20250618/mux.go @@ -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) +} diff --git a/mcp/server20250618/server.go b/mcp/server20250618/server.go new file mode 100644 index 0000000..59cb668 --- /dev/null +++ b/mcp/server20250618/server.go @@ -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 +} diff --git a/mcp/server20250618/server_test.go b/mcp/server20250618/server_test.go new file mode 100644 index 0000000..921f139 --- /dev/null +++ b/mcp/server20250618/server_test.go @@ -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) + } +} diff --git a/mcp/types20250618/resource.go b/mcp/types20250618/resource.go new file mode 100644 index 0000000..550a8a1 --- /dev/null +++ b/mcp/types20250618/resource.go @@ -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) diff --git a/mcp/types20250618/tool.go b/mcp/types20250618/tool.go new file mode 100644 index 0000000..d6236c3 --- /dev/null +++ b/mcp/types20250618/tool.go @@ -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, + } +} diff --git a/mcp/types20250618/types.go b/mcp/types20250618/types.go new file mode 100644 index 0000000..9bb03f2 --- /dev/null +++ b/mcp/types20250618/types.go @@ -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 diff --git a/mcp/types20250618/types_test.go b/mcp/types20250618/types_test.go new file mode 100644 index 0000000..3bf51e3 --- /dev/null +++ b/mcp/types20250618/types_test.go @@ -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) + } +}