diff --git a/mcp/server20260728/doc.go b/mcp/server20260728/doc.go new file mode 100644 index 0000000..346660d --- /dev/null +++ b/mcp/server20260728/doc.go @@ -0,0 +1,16 @@ +// Package mcpserver implements the stateless MCP 2026-07-28 protocol. +// +// The HTTP transport checks Mcp-Method and Mcp-Name before decoding the JSON +// body. Registered handlers receive concrete Go parameter and result types; +// application code does not need a JSON Schema validator or map[string]any. +// +// A typical server registers typed tools and resources: +// +// srv := &mcpserver.MCPServer{Name: "example", Version: "1"} +// mcpserver.RegisterTypedTool(srv, mcptypes.Tool{Name: "echo"}, func(ctx context.Context, headers mcpserver.Headers, args EchoArgs) (mcptypes.CallToolResult, error) { +// return TextResult(args.Message), nil +// }) +// +// Use the request headers for transport-level routing and custom tool headers; +// use typed request decoding for the JSON parameters. +package mcpserver diff --git a/mcp/server20260728/server.go b/mcp/server20260728/server.go new file mode 100644 index 0000000..8680186 --- /dev/null +++ b/mcp/server20260728/server.go @@ -0,0 +1,284 @@ +// Package mcpserver implements the stateless MCP 2026-07-28 server. +// +// HTTP headers are inspected before the JSON body is decoded. Mcp-Method and +// Mcp-Name select the protocol route; x-mcp-header values are made available +// to the selected handler. +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "sync" + + mcptypes "github.com/therootcompany/golib/mcp/types20260728" +) + +const ProtocolVersion = mcptypes.ProtocolVersion + +type Headers struct { + Method string + Name string + Custom map[string][]string +} + +func headersFromHTTP(h http.Header) Headers { + custom := make(map[string][]string) + for key, values := range h { + if strings.EqualFold(key, "x-mcp-header") { + custom["x-mcp-header"] = append([]string(nil), values...) + } + } + return Headers{Method: h.Get("Mcp-Method"), Name: h.Get("Mcp-Name"), Custom: custom} +} + +type CallToolRequest struct { + Headers Headers + Params mcptypes.CallToolParams +} +type ReadResourceRequest struct { + Headers Headers + Params mcptypes.ReadResourceParams +} +type ToolHandlerFunc func(context.Context, CallToolRequest) (mcptypes.CallToolResult, error) +type ResourceHandlerFunc func(context.Context, ReadResourceRequest) (mcptypes.ReadResourceResult, error) + +// RegisterTypedTool decodes a tool's arguments into T before calling handler. +// T is the application Go type for the tool input; no JSON Schema validator is +// involved. +func RegisterTypedTool[T any](s *MCPServer, definition mcptypes.Tool, handler func(context.Context, Headers, T) (mcptypes.CallToolResult, error)) { + s.RegisterTool(definition, func(ctx context.Context, request CallToolRequest) (mcptypes.CallToolResult, error) { + args, err := mcptypes.Decode[T](request.Params.Arguments) + if err != nil { + return mcptypes.CallToolResult{}, fmt.Errorf("decode %s arguments: %w", definition.Name, err) + } + return handler(ctx, request.Headers, args) + }) +} + +type MCPServer struct { + Name string + Title string + Version string + Instructions string + Capabilities mcptypes.ServerCapabilities + AllowedOrigins []string + mu sync.RWMutex + tools []registeredTool + resources []registeredResource +} +type registeredTool struct { + definition mcptypes.Tool + handler ToolHandlerFunc +} +type registeredResource struct { + definition mcptypes.Resource + handler ResourceHandlerFunc +} + +func (s *MCPServer) RegisterTool(definition mcptypes.Tool, handler ToolHandlerFunc) { + s.mu.Lock() + defer s.mu.Unlock() + for _, tool := range s.tools { + if tool.definition.Name == definition.Name { + panic("mcp: duplicate tool: " + definition.Name) + } + } + s.tools = append(s.tools, registeredTool{definition, handler}) +} +func (s *MCPServer) RegisterResource(definition mcptypes.Resource, handler ResourceHandlerFunc) { + s.mu.Lock() + defer s.mu.Unlock() + for _, resource := range s.resources { + if resource.definition.URI == definition.URI { + panic("mcp: duplicate resource: " + definition.URI) + } + } + s.resources = append(s.resources, registeredResource{definition, handler}) +} + +func (s *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + if r.Header.Get("Mcp-Method") == "" { + s.writeError(w, nil, mcptypes.ErrHeaderMismatch, "missing Mcp-Method header") + return + } + if origin := r.Header.Get("Origin"); origin != "" && !contains(s.AllowedOrigins, origin) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20+1)) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if len(body) > 10<<20 { + http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) + return + } + result := s.handle(r.Context(), headersFromHTTP(r.Header), body) + if result == nil { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} + +func (s *MCPServer) handle(ctx context.Context, headers Headers, body []byte) any { + var envelope struct { + JSONRPC string `json:"jsonrpc"` + ID *mcptypes.RequestID `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return s.error(nil, mcptypes.ErrParse, "Parse error") + } + if headers.Method != envelope.Method { + return s.error(envelope.ID, mcptypes.ErrHeaderMismatch, "Mcp-Method does not match request method") + } + var request mcptypes.Request + if err := json.Unmarshal(body, &request); err != nil || request.JSONRPC != mcptypes.JSONRPCVersion || request.Method == "" || (request.IDPresent && (request.ID == nil || request.ID.IsNull())) { + return s.error(envelope.ID, mcptypes.ErrInvalidRequest, "Invalid JSON-RPC request") + } + if request.IsNotification() { + return nil + } + switch request.Method { + case "server/discover": + return s.discover(envelope.ID) + case "tools/list": + return s.toolsList(envelope.ID) + case "tools/call": + return s.toolsCall(ctx, headers, envelope.ID, envelope.Params) + case "resources/list": + return s.resourcesList(envelope.ID) + case "resources/read": + return s.resourceRead(ctx, headers, envelope.ID, envelope.Params) + default: + return s.error(envelope.ID, mcptypes.ErrMethodNotFound, fmt.Sprintf("Method not found: %s", request.Method)) + } +} + +func (s *MCPServer) discover(id *mcptypes.RequestID) any { + value := mcptypes.DiscoverResult{ResultType: "complete", ProtocolVersions: []string{ProtocolVersion}, Capabilities: s.capabilities(), ServerInfo: mcptypes.ServerInfo{Name: s.Name, Title: s.Title, Version: s.Version}, Instructions: s.Instructions} + raw, err := json.Marshal(value) + if err != nil { + return s.error(id, mcptypes.ErrInternal, "Failed to encode result") + } + return s.result(id, raw) +} +func (s *MCPServer) capabilities() mcptypes.ServerCapabilities { + s.mu.RLock() + defer s.mu.RUnlock() + value := s.Capabilities + if value.Tools == nil && len(s.tools) != 0 { + value.Tools = &mcptypes.ToolsCapability{} + } + if value.Resources == nil && len(s.resources) != 0 { + value.Resources = &mcptypes.ResourcesCapability{} + } + return value +} +func (s *MCPServer) toolsList(id *mcptypes.RequestID) any { + s.mu.RLock() + tools := make([]mcptypes.Tool, len(s.tools)) + for i := range s.tools { + tools[i] = s.tools[i].definition + } + s.mu.RUnlock() + raw, err := json.Marshal(mcptypes.ToolsListResult{ResultType: "complete", Tools: tools, CacheScope: mcptypes.CachePrivate}) + if err != nil { + return s.error(id, mcptypes.ErrInternal, "Failed to encode result") + } + return s.result(id, raw) +} +func (s *MCPServer) toolsCall(ctx context.Context, headers Headers, id *mcptypes.RequestID, raw json.RawMessage) any { + params, err := mcptypes.Decode[mcptypes.CallToolParams](raw) + if err != nil || params.Name == "" { + return s.error(id, mcptypes.ErrInvalidParams, "Invalid params for tools/call") + } + s.mu.RLock() + var handler ToolHandlerFunc + for _, tool := range s.tools { + if tool.definition.Name == params.Name { + handler = tool.handler + break + } + } + s.mu.RUnlock() + if handler == nil { + return s.error(id, mcptypes.ErrInvalidParams, "Unknown tool") + } + value, err := handler(ctx, CallToolRequest{headers, params}) + if err != nil { + return s.error(id, mcptypes.ErrInternal, "Tool execution failed") + } + encoded, encodeErr := json.Marshal(value) + if encodeErr != nil { + return s.error(id, mcptypes.ErrInternal, "Failed to encode result") + } + return s.result(id, encoded) +} +func (s *MCPServer) resourcesList(id *mcptypes.RequestID) any { + s.mu.RLock() + resources := make([]mcptypes.Resource, len(s.resources)) + for i := range s.resources { + resources[i] = s.resources[i].definition + } + s.mu.RUnlock() + raw, err := json.Marshal(mcptypes.ResourcesListResult{ResultType: "complete", Resources: resources, CacheScope: mcptypes.CachePrivate}) + if err != nil { + return s.error(id, mcptypes.ErrInternal, "Failed to encode result") + } + return s.result(id, raw) +} +func (s *MCPServer) resourceRead(ctx context.Context, headers Headers, id *mcptypes.RequestID, raw json.RawMessage) any { + params, err := mcptypes.Decode[mcptypes.ReadResourceParams](raw) + if err != nil || params.URI == "" { + return s.error(id, mcptypes.ErrInvalidParams, "Invalid params for resources/read") + } + s.mu.RLock() + var handler ResourceHandlerFunc + for _, resource := range s.resources { + if resource.definition.URI == params.URI { + handler = resource.handler + break + } + } + s.mu.RUnlock() + if handler == nil { + return s.error(id, mcptypes.ErrInvalidParams, "Unknown resource") + } + value, err := handler(ctx, ReadResourceRequest{headers, params}) + if err != nil { + return s.error(id, mcptypes.ErrInternal, "Resource read failed") + } + encoded, encodeErr := json.Marshal(value) + if encodeErr != nil { + return s.error(id, mcptypes.ErrInternal, "Failed to encode result") + } + return s.result(id, encoded) +} + +func (s *MCPServer) result(id *mcptypes.RequestID, value json.RawMessage) mcptypes.Response { + return mcptypes.Response{JSONRPC: mcptypes.JSONRPCVersion, ID: id, Result: value} +} +func (s *MCPServer) error(id *mcptypes.RequestID, code int, message string) mcptypes.ErrorResponse { + return mcptypes.ErrorResponse{JSONRPC: mcptypes.JSONRPCVersion, ID: id, Error: mcptypes.ErrorObject{Code: code, Message: message}} +} +func (s *MCPServer) writeError(w http.ResponseWriter, id *mcptypes.RequestID, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.error(id, code, message)) +} +func contains(values []string, target string) bool { + return slices.Contains(values, target) +} diff --git a/mcp/server20260728/server_test.go b/mcp/server20260728/server_test.go new file mode 100644 index 0000000..115d4b2 --- /dev/null +++ b/mcp/server20260728/server_test.go @@ -0,0 +1,60 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + mcptypes "github.com/therootcompany/golib/mcp/types20260728" +) + +type echoArgs struct { + Message string `json:"message"` +} + +func TestServeHTTPUsesHeadersBeforeTypedDecode(t *testing.T) { + var got Headers + var gotArgs echoArgs + srv := &MCPServer{Name: "test", Version: "1"} + RegisterTypedTool(srv, mcptypes.Tool{Name: "echo", InputSchema: json.RawMessage(`{"type":"object"}`)}, func(_ context.Context, headers Headers, args echoArgs) (mcptypes.CallToolResult, error) { + got = headers + gotArgs = args + return mcptypes.CallToolResult{ResultType: "complete", Content: []mcptypes.Content{{Type: "text", Text: args.Message}}}, nil + }) + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}`)) + req.Header.Set("Mcp-Method", "tools/call") + req.Header.Set("Mcp-Name", "echo") + req.Header.Add("x-mcp-header", "trace-id") + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", res.Code, res.Body) + } + if got.Method != "tools/call" || got.Name != "echo" || len(got.Custom["x-mcp-header"]) != 1 { + t.Fatalf("headers = %#v", got) + } + if gotArgs.Message != "hello" { + t.Fatalf("args = %#v", gotArgs) + } +} + +func TestServeHTTPRejectsHeaderMethodMismatch(t *testing.T) { + srv := &MCPServer{} + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + req.Header.Set("Mcp-Method", "tools/call") + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 JSON-RPC error", res.Code) + } + var response mcptypes.ErrorResponse + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if response.Error.Code != mcptypes.ErrHeaderMismatch { + t.Fatalf("code = %d, want %d", response.Error.Code, mcptypes.ErrHeaderMismatch) + } +} diff --git a/mcp/types20260728/types.go b/mcp/types20260728/types.go new file mode 100644 index 0000000..a84046a --- /dev/null +++ b/mcp/types20260728/types.go @@ -0,0 +1,254 @@ +// Package mcptypes defines the typed MCP 2026-07-28 wire model. +// +// Core protocol values use Go structs. json.RawMessage is used only where the +// specification deliberately permits arbitrary JSON (extensions, JSON Schema, +// and structured tool content). +package mcptypes + +import ( + "bytes" + "encoding/json" + "fmt" +) + +const ( + JSONRPCVersion = "2.0" + ProtocolVersion = "2026-07-28" +) + +type RequestID struct{ value json.RawMessage } + +func NewStringID(value string) RequestID { + return RequestID{value: json.RawMessage(fmt.Sprintf("%q", value))} +} +func NewNumberID(value json.Number) RequestID { + return RequestID{value: json.RawMessage(value.String())} +} +func (id RequestID) IsNull() bool { return len(id.value) == 0 || bytes.Equal(id.value, []byte("null")) } +func (id RequestID) MarshalJSON() ([]byte, error) { + if id.IsNull() { + return []byte("null"), nil + } + return id.value, nil +} +func (id *RequestID) UnmarshalJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return fmt.Errorf("invalid JSON-RPC id: %w", err) + } + if len(value) == 0 { + return fmt.Errorf("invalid JSON-RPC id") + } + switch value[0] { + case '"', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + id.value = append(id.value[:0], value...) + return nil + case 'n': + if bytes.Equal(value, []byte("null")) { + id.value = []byte("null") + return nil + } + } + return fmt.Errorf("JSON-RPC id must be a string, number, or null") +} + +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID *RequestID `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + Meta RequestMeta `json:"_meta"` + IDPresent bool `json:"-"` +} + +func (r *Request) UnmarshalJSON(data []byte) error { + type alias Request + var decoded alias + 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 = Request(decoded) + _, r.IDPresent = fields["id"] + return nil +} +func (r Request) IsNotification() bool { return !r.IDPresent } + +type RequestMeta struct { + ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion,omitempty"` + ClientCapabilities ClientCapabilities `json:"io.modelcontextprotocol/clientCapabilities"` + ClientInfo *ClientInfo `json:"io.modelcontextprotocol/clientInfo,omitempty"` + LogLevel *LogLevel `json:"io.modelcontextprotocol/logLevel,omitempty"` + Extensions map[string]json.RawMessage `json:"-"` +} + +type ResultMeta struct { + ServerInfo *ServerInfo `json:"io.modelcontextprotocol/serverInfo,omitempty"` + SubscriptionID string `json:"io.modelcontextprotocol/subscriptionId,omitempty"` + Extensions map[string]json.RawMessage `json:"-"` +} + +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID *RequestID `json:"id"` + Result json.RawMessage `json:"result"` +} +type ErrorResponse struct { + JSONRPC string `json:"jsonrpc"` + ID *RequestID `json:"id"` + Error ErrorObject `json:"error"` +} +type ErrorObject struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +const ( + ErrParse = -32700 + ErrInvalidRequest = -32600 + ErrMethodNotFound = -32601 + ErrInvalidParams = -32602 + ErrInternal = -32603 + ErrHeaderMismatch = -32020 + ErrMissingRequiredClientCapability = -32021 + ErrUnsupportedProtocolVersion = -32022 +) + +type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} +type ServerInfo struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Version string `json:"version"` +} +type ClientCapabilities struct { + Extensions map[string]json.RawMessage `json:"extensions,omitempty"` +} +type ServerCapabilities struct { + Extensions map[string]json.RawMessage `json:"extensions,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 { + ListChanged bool `json:"listChanged,omitempty"` +} +type ToolsCapability struct { + ListChanged bool `json:"listChanged,omitempty"` +} +type LogLevel string + +// Method parameter and result types. +type DiscoverParams struct{} +type DiscoverResult struct { + ResultType string `json:"resultType"` + ProtocolVersions []string `json:"protocolVersions"` + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo ServerInfo `json:"serverInfo"` + Instructions string `json:"instructions,omitempty"` + Meta ResultMeta `json:"_meta"` +} +type PaginatedParams struct { + Cursor string `json:"cursor,omitempty"` +} +type ToolsListResult struct { + ResultType string `json:"resultType"` + Tools []Tool `json:"tools"` + NextCursor string `json:"nextCursor,omitempty"` + TTLMS int64 `json:"ttlMs"` + CacheScope CacheScope `json:"cacheScope"` + Meta ResultMeta `json:"_meta"` +} +type ResourcesListResult struct { + ResultType string `json:"resultType"` + Resources []Resource `json:"resources"` + NextCursor string `json:"nextCursor,omitempty"` + TTLMS int64 `json:"ttlMs"` + CacheScope CacheScope `json:"cacheScope"` + Meta ResultMeta `json:"_meta"` +} +type ReadResourceParams struct { + URI string `json:"uri"` +} +type ReadResourceResult struct { + ResultType string `json:"resultType"` + Contents []ResourceContents `json:"contents"` + TTLMS int64 `json:"ttlMs"` + CacheScope CacheScope `json:"cacheScope"` + Meta ResultMeta `json:"_meta"` +} +type CallToolParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} +type CallToolResult struct { + ResultType string `json:"resultType"` + Content []Content `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` + Meta ResultMeta `json:"_meta"` +} +type CacheScope string + +const ( + CachePublic CacheScope = "public" + CachePrivate CacheScope = "private" +) + +type Tool struct { + Meta json.RawMessage `json:"_meta,omitempty"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"inputSchema"` + OutputSchema json.RawMessage `json:"outputSchema,omitempty"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` +} +type ToolAnnotations 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"` +} +type Resource struct { + Meta json.RawMessage `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"` + Size *int64 `json:"size,omitempty"` +} +type ResourceContents struct { + Meta json.RawMessage `json:"_meta,omitempty"` + URI string `json:"uri"` + MIMEType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` + Blob string `json:"blob,omitempty"` +} +type Content struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Data string `json:"data,omitempty"` + MIMEType string `json:"mimeType,omitempty"` +} + +func Decode[T any](data json.RawMessage) (T, error) { + var value T + if len(bytes.TrimSpace(data)) == 0 { + data = []byte("{}") + } + err := json.Unmarshal(data, &value) + return value, err +}