diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index d0d40c45..a0834c8f 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -496,6 +496,10 @@ func (c *Config) IsMgmtEnabled() bool { return c.IsSearchEnabled() } +func (c *Config) IsMCPEnabled() bool { + return c.Extensions != nil && c.Extensions.MCP != nil && *c.Extensions.MCP.Enable +} + func (c *Config) IsImageTrustEnabled() bool { return c.Extensions != nil && c.Extensions.Trust != nil && *c.Extensions.Trust.Enable } diff --git a/pkg/api/constants/extensions.go b/pkg/api/constants/extensions.go index 188c4f55..7c3b0ec9 100644 --- a/pkg/api/constants/extensions.go +++ b/pkg/api/constants/extensions.go @@ -33,4 +33,9 @@ const ( UserPrefs = "/userprefs" ExtUserPrefs = ExtPrefix + UserPrefs FullUserPrefs = RoutePrefix + ExtUserPrefs + + // mcp extension. + MCP = "/mcp" + ExtMCP = ExtPrefix + MCP + FullMCP = RoutePrefix + ExtMCP ) diff --git a/pkg/api/routes.go b/pkg/api/routes.go index d24226b1..eac01e94 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -212,6 +212,7 @@ func (rh *RouteHandler) SetupRoutes() { rh.c.Log) ext.SetupImageTrustRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) ext.SetupMgmtRoutes(rh.c.Config, prefixedRouter, rh.c.Log) + ext.SetupMCPRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.MetaDB, rh.c.Log) ext.SetupUserPreferencesRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) // last should always be UI because it will setup a http.FileServer and paths will be resolved by this FileServer. ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.Log) diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index b1f422fe..403df4c2 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -23,6 +23,7 @@ type ExtensionConfig struct { APIKey *APIKeyConfig Trust *ImageTrustConfig Events *events.Config + MCP *MCPConfig } type ImageTrustConfig struct { @@ -39,6 +40,10 @@ type MgmtConfig struct { BaseConfig `mapstructure:",squash"` } +type MCPConfig struct { + BaseConfig `mapstructure:",squash"` +} + type LintConfig struct { BaseConfig `mapstructure:",squash"` MandatoryAnnotations []string diff --git a/pkg/extensions/extension_mcp.go b/pkg/extensions/extension_mcp.go new file mode 100644 index 00000000..76cea4e7 --- /dev/null +++ b/pkg/extensions/extension_mcp.go @@ -0,0 +1,610 @@ +//go:build mcp +// +build mcp + +package extensions + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + + "zotregistry.dev/zot/pkg/api/config" + "zotregistry.dev/zot/pkg/api/constants" + "zotregistry.dev/zot/pkg/extensions/search/gql_generated" + zcommon "zotregistry.dev/zot/pkg/common" + "zotregistry.dev/zot/pkg/log" + mTypes "zotregistry.dev/zot/pkg/meta/types" + "zotregistry.dev/zot/pkg/storage" +) + +func IsBuiltWithMCPExtension() bool { + return true +} + +// MCP (Model Context Protocol) server implementation for zot registry +type MCPServer struct { + Conf *config.Config + Log log.Logger + MetaDB mTypes.MetaDB + StoreController storage.StoreController +} + +// MCPResource represents a resource exposed via MCP +type MCPResource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description"` + MimeType string `json:"mimeType"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// MCPToolCall represents a tool call request +type MCPToolCall struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +// MCPToolResult represents a tool call result +type MCPToolResult struct { + Content []MCPContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// MCPContent represents content in MCP responses +type MCPContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Data string `json:"data,omitempty"` +} + +// MCPListResourcesResponse represents the response for listing resources +type MCPListResourcesResponse struct { + Resources []MCPResource `json:"resources"` +} + +// MCPGetResourceResponse represents the response for getting a resource +type MCPGetResourceResponse struct { + Contents []MCPContent `json:"contents"` +} + +// MCPListToolsResponse represents available tools +type MCPListToolsResponse struct { + Tools []MCPTool `json:"tools"` +} + +// MCPTool represents an available MCP tool +type MCPTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` +} + +// SetupMCPRoutes sets up MCP extension routes +func SetupMCPRoutes(conf *config.Config, router *mux.Router, storeController storage.StoreController, + metaDB mTypes.MetaDB, log log.Logger, +) { + log.Info().Msg("setting up MCP routes") + + if !conf.IsSearchEnabled() { + log.Warn().Msg("MCP extension requires search extension to be enabled") + return + } + + mcpServer := &MCPServer{ + Conf: conf, + Log: log, + MetaDB: metaDB, + StoreController: storeController, + } + + allowedMethods := zcommon.AllowedMethods(http.MethodGet, http.MethodPost) + + mcpRouter := router.PathPrefix(constants.ExtMCP).Subrouter() + mcpRouter.Use(zcommon.CORSHeadersMiddleware(conf.HTTP.AllowOrigin)) + mcpRouter.Use(zcommon.AddExtensionSecurityHeaders()) + mcpRouter.Use(zcommon.ACHeadersMiddleware(conf, allowedMethods...)) + + // MCP endpoints + mcpRouter.Methods(http.MethodGet).Path("/resources").HandlerFunc(mcpServer.ListResources) + mcpRouter.Methods(http.MethodGet).Path("/resources/{uri}").HandlerFunc(mcpServer.GetResource) + mcpRouter.Methods(http.MethodGet).Path("/tools").HandlerFunc(mcpServer.ListTools) + mcpRouter.Methods(http.MethodPost).Path("/tools/{name}").HandlerFunc(mcpServer.CallTool) + + log.Info().Msg("finished setting up MCP routes") +} + +// ListResources lists all available MCP resources +func (mcp *MCPServer) ListResources(w http.ResponseWriter, r *http.Request) { + resources := []MCPResource{ + { + URI: "registry://repositories", + Name: "Registry Repositories", + Description: "List of all repositories in the registry", + MimeType: "application/json", + Metadata: map[string]string{ + "type": "repository_list", + }, + }, + { + URI: "registry://images", + Name: "Registry Images", + Description: "List of all images in the registry", + MimeType: "application/json", + Metadata: map[string]string{ + "type": "image_list", + }, + }, + { + URI: "registry://vulnerabilities", + Name: "CVE Information", + Description: "Vulnerability information for registry images", + MimeType: "application/json", + Metadata: map[string]string{ + "type": "cve_list", + }, + }, + { + URI: "registry://annotations", + Name: "Image Annotations", + Description: "Annotations and metadata for registry images", + MimeType: "application/json", + Metadata: map[string]string{ + "type": "annotation_list", + }, + }, + } + + response := MCPListResourcesResponse{ + Resources: resources, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// GetResource retrieves a specific MCP resource +func (mcp *MCPServer) GetResource(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + uri := vars["uri"] + + var contents []MCPContent + var err error + + switch uri { + case "registry://repositories": + contents, err = mcp.getRepositoriesResource() + case "registry://images": + contents, err = mcp.getImagesResource() + case "registry://vulnerabilities": + contents, err = mcp.getVulnerabilitiesResource() + case "registry://annotations": + contents, err = mcp.getAnnotationsResource() + default: + http.Error(w, "Resource not found", http.StatusNotFound) + return + } + + if err != nil { + mcp.Log.Error().Err(err).Str("uri", uri).Msg("failed to get MCP resource") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + response := MCPGetResourceResponse{ + Contents: contents, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// ListTools lists available MCP tools +func (mcp *MCPServer) ListTools(w http.ResponseWriter, r *http.Request) { + tools := []MCPTool{ + { + Name: "search_repositories", + Description: "Search for repositories in the registry", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query string", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of results to return", + "default": 10, + }, + }, + "required": []string{"query"}, + }, + }, + { + Name: "search_images", + Description: "Search for images in the registry", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query string", + }, + "tag": map[string]interface{}{ + "type": "string", + "description": "Filter by specific tag", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of results to return", + "default": 10, + }, + }, + "required": []string{"query"}, + }, + }, + { + Name: "get_cve_info", + Description: "Get CVE information for a specific image", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "repository": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "tag": map[string]interface{}{ + "type": "string", + "description": "Image tag", + }, + }, + "required": []string{"repository", "tag"}, + }, + }, + } + + response := MCPListToolsResponse{ + Tools: tools, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// CallTool handles MCP tool calls +func (mcp *MCPServer) CallTool(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + toolName := vars["name"] + + var toolCall MCPToolCall + if err := json.NewDecoder(r.Body).Decode(&toolCall); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + var result MCPToolResult + var err error + + switch toolName { + case "search_repositories": + result, err = mcp.handleSearchRepositories(toolCall.Arguments) + case "search_images": + result, err = mcp.handleSearchImages(toolCall.Arguments) + case "get_cve_info": + result, err = mcp.handleGetCVEInfo(toolCall.Arguments) + default: + result = MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Tool not found", + }}, + IsError: true, + } + } + + if err != nil { + mcp.Log.Error().Err(err).Str("tool", toolName).Msg("failed to execute MCP tool") + result = MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Internal server error: " + err.Error(), + }}, + IsError: true, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// Helper methods for getting resources + +func (mcp *MCPServer) getRepositoriesResource() ([]MCPContent, error) { + if mcp.MetaDB == nil { + return []MCPContent{{ + Type: "text", + Text: "MetaDB not available - search extension may not be enabled", + }}, nil + } + + // Get repositories from MetaDB + repoMetas, err := mcp.MetaDB.GetAllRepoNames() + if err != nil { + return nil, err + } + + data, err := json.MarshalIndent(map[string]interface{}{ + "repositories": repoMetas, + "count": len(repoMetas), + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return nil, err + } + + return []MCPContent{{ + Type: "text", + Text: string(data), + }}, nil +} + +func (mcp *MCPServer) getImagesResource() ([]MCPContent, error) { + if mcp.MetaDB == nil { + return []MCPContent{{ + Type: "text", + Text: "MetaDB not available - search extension may not be enabled", + }}, nil + } + + // Get basic image information + repoMetas, err := mcp.MetaDB.GetAllRepoNames() + if err != nil { + return nil, err + } + + var imageList []map[string]interface{} + for _, repoName := range repoMetas { + // For each repo, get basic tag information + repoMeta, err := mcp.MetaDB.GetRepoMeta(r.Context(), repoName) + if err != nil { + continue + } + + for tag := range repoMeta.Tags { + imageList = append(imageList, map[string]interface{}{ + "repository": repoName, + "tag": tag, + }) + } + } + + data, err := json.MarshalIndent(map[string]interface{}{ + "images": imageList, + "count": len(imageList), + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return nil, err + } + + return []MCPContent{{ + Type: "text", + Text: string(data), + }}, nil +} + +func (mcp *MCPServer) getVulnerabilitiesResource() ([]MCPContent, error) { + // Placeholder for CVE information + // In a real implementation, this would integrate with the CVE scanner + data, err := json.MarshalIndent(map[string]interface{}{ + "message": "CVE scanning requires additional configuration", + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return nil, err + } + + return []MCPContent{{ + Type: "text", + Text: string(data), + }}, nil +} + +func (mcp *MCPServer) getAnnotationsResource() ([]MCPContent, error) { + // Placeholder for annotations + data, err := json.MarshalIndent(map[string]interface{}{ + "message": "Annotations information available through image metadata", + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return nil, err + } + + return []MCPContent{{ + Type: "text", + Text: string(data), + }}, nil +} + +// Helper methods for tool calls + +func (mcp *MCPServer) handleSearchRepositories(args map[string]interface{}) (MCPToolResult, error) { + query, ok := args["query"].(string) + if !ok { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Query parameter is required", + }}, + IsError: true, + }, nil + } + + limit := 10 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + if mcp.MetaDB == nil { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Search not available - MetaDB not initialized", + }}, + IsError: true, + }, nil + } + + // Search repositories + repoMetas, err := mcp.MetaDB.SearchRepos(r.Context(), query) + if err != nil { + return MCPToolResult{}, err + } + + // Limit results + if len(repoMetas) > limit { + repoMetas = repoMetas[:limit] + } + + // Convert to simple format + var results []map[string]interface{} + for _, repo := range repoMetas { + results = append(results, map[string]interface{}{ + "name": repo.Name, + "lastUpdated": repo.LastUpdatedImage.LastUpdated, + "size": repo.Size, + "platforms": repo.Platforms, + }) + } + + data, err := json.MarshalIndent(map[string]interface{}{ + "query": query, + "results": results, + "count": len(results), + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return MCPToolResult{}, err + } + + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(data), + }}, + }, nil +} + +func (mcp *MCPServer) handleSearchImages(args map[string]interface{}) (MCPToolResult, error) { + query, ok := args["query"].(string) + if !ok { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Query parameter is required", + }}, + IsError: true, + }, nil + } + + limit := 10 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + if mcp.MetaDB == nil { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Search not available - MetaDB not initialized", + }}, + IsError: true, + }, nil + } + + // Search images + imageMetas, err := mcp.MetaDB.SearchTags(r.Context(), query) + if err != nil { + return MCPToolResult{}, err + } + + // Limit results + if len(imageMetas) > limit { + imageMetas = imageMetas[:limit] + } + + // Convert to simple format + var results []map[string]interface{} + for _, image := range imageMetas { + results = append(results, map[string]interface{}{ + "repository": image.Repo, + "tag": image.Tag, + "digest": image.Digest, + "lastUpdated": image.LastUpdated, + "size": image.Size, + }) + } + + data, err := json.MarshalIndent(map[string]interface{}{ + "query": query, + "results": results, + "count": len(results), + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return MCPToolResult{}, err + } + + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(data), + }}, + }, nil +} + +func (mcp *MCPServer) handleGetCVEInfo(args map[string]interface{}) (MCPToolResult, error) { + repository, ok := args["repository"].(string) + if !ok { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Repository parameter is required", + }}, + IsError: true, + }, nil + } + + tag, ok := args["tag"].(string) + if !ok { + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "Tag parameter is required", + }}, + IsError: true, + }, nil + } + + // Placeholder for CVE information + // In a real implementation, this would integrate with the CVE scanner + data, err := json.MarshalIndent(map[string]interface{}{ + "repository": repository, + "tag": tag, + "message": "CVE scanning requires CVE scanner to be configured and enabled", + "timestamp": time.Now().Format(time.RFC3339), + }, "", " ") + if err != nil { + return MCPToolResult{}, err + } + + return MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: string(data), + }}, + }, nil +} diff --git a/pkg/extensions/extension_mcp_disabled.go b/pkg/extensions/extension_mcp_disabled.go new file mode 100644 index 00000000..dddcac8f --- /dev/null +++ b/pkg/extensions/extension_mcp_disabled.go @@ -0,0 +1,24 @@ +//go:build !mcp +// +build !mcp + +package extensions + +import ( + "github.com/gorilla/mux" + + "zotregistry.dev/zot/pkg/api/config" + "zotregistry.dev/zot/pkg/log" + mTypes "zotregistry.dev/zot/pkg/meta/types" + "zotregistry.dev/zot/pkg/storage" +) + +func IsBuiltWithMCPExtension() bool { + return false +} + +func SetupMCPRoutes(config *config.Config, router *mux.Router, storeController storage.StoreController, + metaDB mTypes.MetaDB, log log.Logger, +) { + log.Warn().Msg("skipping setting up MCP routes because given zot binary " + + "doesn't include this feature, please build a binary that does so") +} diff --git a/pkg/extensions/get_extensions.go b/pkg/extensions/get_extensions.go index 47dfef13..fcc5a297 100644 --- a/pkg/extensions/get_extensions.go +++ b/pkg/extensions/get_extensions.go @@ -36,6 +36,10 @@ func GetExtensions(config *config.Config) distext.ExtensionList { endpoints = append(endpoints, constants.FullMgmt) } + if config.IsMCPEnabled() && IsBuiltWithMCPExtension() { + endpoints = append(endpoints, constants.FullMCP) + } + if len(endpoints) > 0 { extensions = append(extensions, distext.Extension{ Name: constants.BaseExtension,