diff --git a/.github/workflows/publish-landscape-mcp-server.yml b/.github/workflows/publish-landscape-mcp-server.yml new file mode 100644 index 00000000..ef793e9d --- /dev/null +++ b/.github/workflows/publish-landscape-mcp-server.yml @@ -0,0 +1,79 @@ +--- +name: Build and Publish Landscape MCP Server + +on: + push: + branches: ['**'] + paths: + - 'utilities/landscape-mcp-server/**' + - '.github/workflows/publish-landscape-mcp-server.yml' + pull_request: + branches: [main] + paths: + - 'utilities/landscape-mcp-server/**' + - '.github/workflows/publish-landscape-mcp-server.yml' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: cncf/landscape-mcp-server + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha + type=ref,event=branch + type=ref,event=pr + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: ./utilities/landscape-mcp-server/ + platforms: | + linux/amd64 + linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@main + + - name: Sign the published Docker image + if: github.event_name != 'pull_request' + env: + COSIGN_EXPERIMENTAL: "true" + run: | + echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign -y {}@${{ steps.build-and-push.outputs.digest }} diff --git a/utilities/landscape-mcp-server/Dockerfile b/utilities/landscape-mcp-server/Dockerfile new file mode 100644 index 00000000..a5dd91c8 --- /dev/null +++ b/utilities/landscape-mcp-server/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 + +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY *.go ./ + +RUN go build -o landscape2-mcp-server + +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY --from=builder /app/landscape2-mcp-server /usr/local/bin/landscape2-mcp-server + +ENTRYPOINT ["landscape2-mcp-server"] diff --git a/utilities/landscape-mcp-server/README.md b/utilities/landscape-mcp-server/README.md new file mode 100644 index 00000000..447dd49f --- /dev/null +++ b/utilities/landscape-mcp-server/README.md @@ -0,0 +1,27 @@ +# Landscape MCP Server + +Easily ask questions of the CNCF (Or other!) Landscape. + +## Installation + +Add this server to your mcp.json (or equivalent): + +``` +"cncf-landscape": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "ghcr.io/cncf/landscape-mcp-server:main", + "--data-url", + "https://landscape.cncf.io/data/full.json" + ] + } +``` + +## Examples + +- How many CNCF projects graduated in 2024? +- When did OpenTelemetry reach incubating? +- What CNCF projects moved levels in 2025? \ No newline at end of file diff --git a/utilities/landscape-mcp-server/dataset_loader.go b/utilities/landscape-mcp-server/dataset_loader.go new file mode 100644 index 00000000..65a437ad --- /dev/null +++ b/utilities/landscape-mcp-server/dataset_loader.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "os" + "strings" + "time" +) + +type dataSource struct { + filePath string + url string +} + +func loadDataset(ctx context.Context, src dataSource) (*Dataset, error) { + raw, err := readSource(ctx, src.filePath, src.url) + if err != nil { + return nil, fmt.Errorf("load data source: %w", err) + } + + var full fullDataset + if err := json.Unmarshal(raw, &full); err != nil { + return nil, fmt.Errorf("parse full dataset: %w", err) + } + + items := make([]LandscapeItem, 0, len(full.Items)) + for _, it := range full.Items { + accepted, err := parseDate(it.AcceptedAt) + if err != nil { + log.Printf("warning: invalid accepted_at for %s: %v", it.Name, err) + } + graduated, err := parseDate(it.GraduatedAt) + if err != nil { + log.Printf("warning: invalid graduated_at for %s: %v", it.Name, err) + } + incubating, err := parseDate(it.IncubatingAt) + if err != nil { + log.Printf("warning: invalid incubating_at for %s: %v", it.Name, err) + } + joined, err := parseDate(it.JoinedAt) + if err != nil { + log.Printf("warning: invalid joined_at for %s: %v", it.Name, err) + } + + item := LandscapeItem{ + Name: it.Name, + Category: it.Category, + Subcategory: it.Subcategory, + MemberSubcategory: strings.TrimSpace(it.MemberSubcategory), + Maturity: strings.TrimSpace(it.Maturity), + JoinedAt: joined, + AcceptedAt: accepted, + GraduatedAt: graduated, + IncubatingAt: incubating, + CrunchbaseURL: strings.TrimSpace(it.CrunchbaseURL), + } + items = append(items, item) + } + + orgs := make(map[string]CrunchbaseOrganization, len(full.CrunchbaseData)) + for url, org := range full.CrunchbaseData { + rounds := make([]FundingRound, 0, len(org.FundingRounds)) + for _, round := range org.FundingRounds { + date, err := parseDate(round.AnnouncedOn) + if err != nil { + log.Printf("warning: invalid announced_on for %s: %v", url, err) + continue + } + + var amountPtr *int64 + if round.Amount != nil { + amt := int64(math.Round(*round.Amount)) + amountPtr = &amt + } + + rounds = append(rounds, FundingRound{ + Amount: amountPtr, + AnnouncedOn: date, + Kind: strings.TrimSpace(round.Kind), + }) + } + orgs[url] = CrunchbaseOrganization{FundingRounds: rounds} + } + + return &Dataset{ + Items: items, + CrunchbaseOrgs: orgs, + }, nil +} + +func readSource(ctx context.Context, filePath, url string) ([]byte, error) { + switch { + case filePath != "": + return os.ReadFile(filePath) + case url != "": + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) + default: + return nil, errors.New("either file or url must be provided") + } +} + +func parseDate(value string) (*time.Time, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + t, err := time.Parse("2006-01-02", value) + if err != nil { + return nil, err + } + tt := t + return &tt, nil +} + +type fullDataset struct { + Items []fullItem `json:"items"` + CrunchbaseData map[string]fullOrganization `json:"crunchbase_data"` +} + +type fullItem struct { + Name string `json:"name"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + MemberSubcategory string `json:"member_subcategory"` + Maturity string `json:"maturity"` + JoinedAt string `json:"joined_at"` + AcceptedAt string `json:"accepted_at"` + GraduatedAt string `json:"graduated_at"` + IncubatingAt string `json:"incubating_at"` + CrunchbaseURL string `json:"crunchbase_url"` +} + +type fullOrganization struct { + FundingRounds []fullFundingRound `json:"funding_rounds"` +} + +type fullFundingRound struct { + Amount *float64 `json:"amount"` + AnnouncedOn string `json:"announced_on"` + Kind string `json:"kind"` +} diff --git a/utilities/landscape-mcp-server/go.mod b/utilities/landscape-mcp-server/go.mod new file mode 100644 index 00000000..82493769 --- /dev/null +++ b/utilities/landscape-mcp-server/go.mod @@ -0,0 +1,3 @@ +module github.com/cncf/landscape2/go/mcp-server + +go 1.21 diff --git a/utilities/landscape-mcp-server/main.go b/utilities/landscape-mcp-server/main.go new file mode 100644 index 00000000..804212b3 --- /dev/null +++ b/utilities/landscape-mcp-server/main.go @@ -0,0 +1,550 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "strings" + "sync" + "time" +) + +// JSON-RPC structures ------------------------------------------------------- + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + ID json.RawMessage `json:"id,omitempty"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` + ID json.RawMessage `json:"id,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Server state -------------------------------------------------------------- + +type serverState struct { + once sync.Once + ready chan struct{} + mu sync.RWMutex + dataset *Dataset + loadErr error +} + +var outputMu sync.Mutex + +type toolDefinition struct { + Name string + Description string + Metrics []string +} + +var ( + toolCatalog = map[string]toolDefinition{ + "project_metrics": { + Name: "project_metrics", + Description: "Answer questions about CNCF project counts and progress.", + Metrics: []string{ + "incubating_project_count", + "sandbox_projects_joined_this_year", + "projects_graduated_last_year", + }, + }, + "membership_metrics": { + Name: "membership_metrics", + Description: "Answer questions about CNCF membership activity and funding.", + Metrics: []string{ + "gold_members_joined_this_year", + "silver_members_joined_this_year", + "silver_members_raised_last_month", + "gold_members_raised_this_year", + }, + }, + "query_projects": { + Name: "query_projects", + Description: "Query CNCF projects with flexible filtering by maturity, dates, and name. Returns detailed project information.", + }, + "query_members": { + Name: "query_members", + Description: "Query CNCF members with flexible filtering by membership tier and join dates.", + }, + "get_project_details": { + Name: "get_project_details", + Description: "Get detailed information about a specific CNCF project by name.", + }, + // Legacy aggregated tool retained for compatibility with earlier clients. + "query_landscape": { + Name: "query_landscape", + Description: "Run predefined analytical queries over the CNCF landscape dataset.", + Metrics: []string{ + "incubating_project_count", + "sandbox_projects_joined_this_year", + "projects_graduated_last_year", + "gold_members_joined_this_year", + "silver_members_joined_this_year", + "silver_members_raised_last_month", + "gold_members_raised_this_year", + }, + }, + } + advertisedTools = []string{ + "query_projects", + "query_members", + "get_project_details", + "project_metrics", + "membership_metrics", + } +) + +func newServerState() *serverState { + return &serverState{ready: make(chan struct{})} +} + +func (s *serverState) setDataset(ds *Dataset, err error) { + s.once.Do(func() { + s.mu.Lock() + defer s.mu.Unlock() + s.dataset = ds + s.loadErr = err + close(s.ready) + }) +} + +func (s *serverState) waitForDataset(ctx context.Context) (*Dataset, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.ready: + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.loadErr != nil { + return nil, s.loadErr + } + if s.dataset == nil { + return nil, errors.New("dataset not loaded") + } + return s.dataset, nil +} + +// Main ---------------------------------------------------------------------- + +func main() { + dataFile := flag.String("data-file", "", "Path to the landscape full dataset JSON file") + dataURL := flag.String("data-url", "https://landscape.cncf.io/data/full.json", "URL to the landscape full dataset JSON file") + flag.Parse() + + log.SetOutput(os.Stderr) + log.SetFlags(0) + + state := newServerState() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + ds, err := loadDataset(ctx, dataSource{ + filePath: *dataFile, + url: *dataURL, + }) + state.setDataset(ds, err) + if err != nil { + log.Printf("error loading dataset: %v", err) + sendNotification("notifications/serverReady", map[string]interface{}{"error": err.Error()}) + } else { + log.Printf("dataset loaded (%d items)", len(ds.Items)) + sendNotification("notifications/serverReady", map[string]interface{}{}) + } + }() + + scanner := bufio.NewScanner(os.Stdin) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var req jsonRPCRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + log.Printf("unable to parse JSON-RPC request: %v", err) + continue + } + + if resp := handleRequest(ctx, &req, state); resp != nil { + writeResponse(resp) + } + } + + if err := scanner.Err(); err != nil { + log.Printf("stdin scanner error: %v", err) + } +} + +func handleRequest(ctx context.Context, req *jsonRPCRequest, state *serverState) *jsonRPCResponse { + switch req.Method { + case "initialize": + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{ + "tools": map[string]interface{}{}, + }, + "serverInfo": map[string]string{ + "name": "landscape2-mcp-server-go", + "version": "0.1.0", + }, + }), + ID: req.ID, + } + case "notifications/initialized": + return nil + case "tools/list": + tools := make([]map[string]interface{}, 0, len(advertisedTools)) + for _, name := range advertisedTools { + def, ok := toolCatalog[name] + if !ok { + continue + } + tools = append(tools, map[string]interface{}{ + "name": def.Name, + "description": def.Description, + "inputSchema": toolInputSchema(def), + }) + } + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{ + "tools": tools, + }), + ID: req.ID, + } + case "tools/call": + return handleToolsCall(ctx, req, state) + default: + return errorResponse(req.ID, -32601, "Method not found", nil) + } +} + +func handleToolsCall(ctx context.Context, req *jsonRPCRequest, state *serverState) *jsonRPCResponse { + var payload struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &payload); err != nil { + return errorResponse(req.ID, -32602, "Invalid params", nil) + } + + def, ok := toolCatalog[payload.Name] + if !ok { + return errorResponse(req.ID, -32601, "Tool not found", nil) + } + + dataset, err := state.waitForDataset(ctx) + if err != nil { + return errorResponse(req.ID, -32603, "Dataset unavailable", mustJSON(map[string]string{"error": err.Error()})) + } + + // Handle different tool types + switch payload.Name { + case "query_projects": + return handleQueryProjects(req.ID, payload.Arguments, dataset) + case "query_members": + return handleQueryMembers(req.ID, payload.Arguments, dataset) + case "get_project_details": + return handleGetProjectDetails(req.ID, payload.Arguments, dataset) + default: + // Handle metric-based tools + var args struct { + Metric string `json:"metric"` + } + if len(def.Metrics) > 0 { + if len(payload.Arguments) > 0 { + if err := json.Unmarshal(payload.Arguments, &args); err != nil { + return errorResponse(req.ID, -32602, "Invalid arguments", nil) + } + } + if args.Metric == "" { + return errorResponse(req.ID, -32602, "Metric is required", mustJSON(map[string]interface{}{ + "allowedMetrics": def.Metrics, + })) + } + if !metricAllowed(def.Metrics, args.Metric) { + return errorResponse(req.ID, -32602, fmt.Sprintf("Unsupported metric %q", args.Metric), mustJSON(map[string]interface{}{ + "allowedMetrics": def.Metrics, + })) + } + } + + result, err := executeMetric(args.Metric, dataset, time.Now().UTC()) + if err != nil { + return errorResponse(req.ID, -32000, err.Error(), nil) + } + + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{"content": []map[string]string{{"type": "text", "text": result}}}), + ID: req.ID, + } + } +} + +func mustJSON(v interface{}) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + +func toolInputSchema(def toolDefinition) map[string]interface{} { + schema := map[string]interface{}{ + "type": "object", + "additionalProperties": false, + } + + // Special schemas for new query tools + switch def.Name { + case "query_projects": + schema["properties"] = map[string]interface{}{ + "maturity": map[string]interface{}{ + "type": "string", + "description": "Filter by maturity level (e.g., graduated, incubating, sandbox)", + }, + "name": map[string]interface{}{ + "type": "string", + "description": "Filter by project name (case-insensitive substring match)", + }, + "graduated_from": map[string]interface{}{ + "type": "string", + "description": "Filter projects graduated on or after this date (YYYY-MM-DD)", + }, + "graduated_to": map[string]interface{}{ + "type": "string", + "description": "Filter projects graduated on or before this date (YYYY-MM-DD)", + }, + "incubating_from": map[string]interface{}{ + "type": "string", + "description": "Filter projects that reached incubating on or after this date (YYYY-MM-DD)", + }, + "incubating_to": map[string]interface{}{ + "type": "string", + "description": "Filter projects that reached incubating on or before this date (YYYY-MM-DD)", + }, + "accepted_from": map[string]interface{}{ + "type": "string", + "description": "Filter projects accepted on or after this date (YYYY-MM-DD)", + }, + "accepted_to": map[string]interface{}{ + "type": "string", + "description": "Filter projects accepted on or before this date (YYYY-MM-DD)", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of results to return (default: 100)", + }, + } + return schema + case "query_members": + schema["properties"] = map[string]interface{}{ + "tier": map[string]interface{}{ + "type": "string", + "description": "Filter by membership tier (e.g., Gold, Silver, Platinum, End User Supporter)", + }, + "joined_from": map[string]interface{}{ + "type": "string", + "description": "Filter members joined on or after this date (YYYY-MM-DD)", + }, + "joined_to": map[string]interface{}{ + "type": "string", + "description": "Filter members joined on or before this date (YYYY-MM-DD)", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of results to return (default: 100)", + }, + } + return schema + case "get_project_details": + schema["properties"] = map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "Project name to search for (case-insensitive)", + }, + } + schema["required"] = []string{"name"} + return schema + } + + // Default metric-based schema + if len(def.Metrics) > 0 { + schema["properties"] = map[string]interface{}{ + "metric": map[string]interface{}{ + "type": "string", + "enum": def.Metrics, + }, + } + schema["required"] = []string{"metric"} + } else { + schema["properties"] = map[string]interface{}{} + } + return schema +} + +func writeResponse(resp *jsonRPCResponse) { + outputMu.Lock() + defer outputMu.Unlock() + + data, err := json.Marshal(resp) + if err != nil { + log.Printf("unable to marshal response: %v", err) + return + } + fmt.Println(string(data)) +} + +func sendNotification(method string, params interface{}) { + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "method": method, + } + if params != nil { + payload["params"] = params + } + + outputMu.Lock() + defer outputMu.Unlock() + + data, err := json.Marshal(payload) + if err != nil { + log.Printf("unable to marshal notification %s: %v", method, err) + return + } + fmt.Println(string(data)) +} + +func errorResponse(id json.RawMessage, code int, message string, data json.RawMessage) *jsonRPCResponse { + return &jsonRPCResponse{ + JSONRPC: "2.0", + Error: &jsonRPCError{ + Code: code, + Message: message, + Data: data, + }, + ID: id, + } +} + +func metricAllowed(allowed []string, metric string) bool { + for _, m := range allowed { + if m == metric { + return true + } + } + return false +} + +func handleQueryProjects(id json.RawMessage, argsRaw json.RawMessage, ds *Dataset) *jsonRPCResponse { + var args struct { + Maturity string `json:"maturity"` + Name string `json:"name"` + GraduatedFrom string `json:"graduated_from"` + GraduatedTo string `json:"graduated_to"` + IncubatingFrom string `json:"incubating_from"` + IncubatingTo string `json:"incubating_to"` + AcceptedFrom string `json:"accepted_from"` + AcceptedTo string `json:"accepted_to"` + Limit int `json:"limit"` + } + if len(argsRaw) > 0 { + if err := json.Unmarshal(argsRaw, &args); err != nil { + return errorResponse(id, -32602, "Invalid arguments", nil) + } + } + if args.Limit == 0 { + args.Limit = 100 + } + + result, err := queryProjects(ds, args.Maturity, args.Name, args.GraduatedFrom, args.GraduatedTo, args.IncubatingFrom, args.IncubatingTo, args.AcceptedFrom, args.AcceptedTo, args.Limit) + if err != nil { + return errorResponse(id, -32000, err.Error(), nil) + } + + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{"content": []map[string]string{{"type": "text", "text": result}}}), + ID: id, + } +} + +func handleQueryMembers(id json.RawMessage, argsRaw json.RawMessage, ds *Dataset) *jsonRPCResponse { + var args struct { + Tier string `json:"tier"` + JoinedFrom string `json:"joined_from"` + JoinedTo string `json:"joined_to"` + Limit int `json:"limit"` + } + if len(argsRaw) > 0 { + if err := json.Unmarshal(argsRaw, &args); err != nil { + return errorResponse(id, -32602, "Invalid arguments", nil) + } + } + if args.Limit == 0 { + args.Limit = 100 + } + + result, err := queryMembers(ds, args.Tier, args.JoinedFrom, args.JoinedTo, args.Limit) + if err != nil { + return errorResponse(id, -32000, err.Error(), nil) + } + + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{"content": []map[string]string{{"type": "text", "text": result}}}), + ID: id, + } +} + +func handleGetProjectDetails(id json.RawMessage, argsRaw json.RawMessage, ds *Dataset) *jsonRPCResponse { + var args struct { + Name string `json:"name"` + } + if len(argsRaw) > 0 { + if err := json.Unmarshal(argsRaw, &args); err != nil { + return errorResponse(id, -32602, "Invalid arguments", nil) + } + } + if args.Name == "" { + return errorResponse(id, -32602, "name parameter is required", nil) + } + + result, err := getProjectDetails(ds, args.Name) + if err != nil { + return errorResponse(id, -32000, err.Error(), nil) + } + + return &jsonRPCResponse{ + JSONRPC: "2.0", + Result: mustJSON(map[string]interface{}{"content": []map[string]string{{"type": "text", "text": result}}}), + ID: id, + } +} diff --git a/utilities/landscape-mcp-server/metrics.go b/utilities/landscape-mcp-server/metrics.go new file mode 100644 index 00000000..2f3e7794 --- /dev/null +++ b/utilities/landscape-mcp-server/metrics.go @@ -0,0 +1,493 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +func executeMetric(metric string, ds *Dataset, now time.Time) (string, error) { + switch metric { + case "incubating_project_count": + count := 0 + for _, item := range ds.Items { + if strings.EqualFold(item.Maturity, "incubating") { + count++ + } + } + return encodeResult(metric, map[string]interface{}{ + "value": count, + "description": "Total CNCF projects currently in incubating maturity", + }) + + case "sandbox_projects_joined_this_year": + year := now.Year() + count := 0 + for _, item := range ds.Items { + if !strings.EqualFold(item.Maturity, "sandbox") { + continue + } + if item.AcceptedAt != nil && item.AcceptedAt.Year() == year { + count++ + } + } + return encodeResult(metric, map[string]interface{}{ + "value": count, + "year": year, + "description": "Sandbox projects accepted into CNCF this year", + }) + + case "projects_graduated_last_year": + targetYear := now.AddDate(-1, 0, 0).Year() + count := 0 + for _, item := range ds.Items { + if item.GraduatedAt != nil && item.GraduatedAt.Year() == targetYear { + count++ + } + } + return encodeResult(metric, map[string]interface{}{ + "value": count, + "year": targetYear, + "description": "Projects that achieved graduated status last year", + }) + + case "gold_members_joined_this_year": + year := now.Year() + members := membersJoinedByTier(ds, "Gold", year) + return encodeResult(metric, map[string]interface{}{ + "value": len(members), + "year": year, + "description": "Gold members that joined CNCF this year", + "members": members, + }) + + case "silver_members_joined_this_year": + year := now.Year() + members := membersJoinedByTier(ds, "Silver", year) + return encodeResult(metric, map[string]interface{}{ + "value": len(members), + "year": year, + "description": "Silver members that joined CNCF this year", + "members": members, + }) + + case "silver_members_raised_last_month": + prev := now.AddDate(0, -1, 0) + events := fundingEventsForPeriod(ds, "Silver", prev.Year(), prev.Month()) + members := uniqueMembers(events) + return encodeResult(metric, map[string]interface{}{ + "value": len(members), + "year": prev.Year(), + "month": int(prev.Month()), + "description": "Silver members with funding rounds last month", + "members": members, + "events": events, + }) + + case "gold_members_raised_this_year": + year := now.Year() + events := fundingEventsForPeriod(ds, "Gold", year, 0) + members := uniqueMembers(events) + return encodeResult(metric, map[string]interface{}{ + "value": len(members), + "year": year, + "description": "Gold members with funding rounds announced this year", + "members": members, + "events": events, + }) + + default: + return "", fmt.Errorf("unknown metric: %s", metric) + } +} + +func encodeResult(metric string, payload map[string]interface{}) (string, error) { + payload["metric"] = metric + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +func isMemberTier(item LandscapeItem, tier string) bool { + if !strings.Contains(strings.ToLower(item.Category), "member") { + return false + } + if strings.EqualFold(item.MemberSubcategory, tier) { + return true + } + return strings.EqualFold(item.Subcategory, tier) +} + +type fundingEvent struct { + Member string `json:"member"` + AnnouncedOn string `json:"announced_on"` + AmountUSD *int64 `json:"amount_usd,omitempty"` + FundingType string `json:"funding_type,omitempty"` +} + +func fundingEventsForPeriod(ds *Dataset, tier string, year int, month time.Month) []fundingEvent { + var events []fundingEvent + for _, item := range ds.Items { + if !isMemberTier(item, tier) { + continue + } + org, ok := ds.CrunchbaseOrgs[item.CrunchbaseURL] + if !ok { + continue + } + for _, round := range org.FundingRounds { + if round.AnnouncedOn == nil { + continue + } + if year > 0 && round.AnnouncedOn.Year() != year { + continue + } + if month != 0 && round.AnnouncedOn.Month() != month { + continue + } + // Capture only one event per round, but multiple per org if needed + ev := fundingEvent{ + Member: item.Name, + AnnouncedOn: round.AnnouncedOn.Format("2006-01-02"), + } + if round.Amount != nil { + ev.AmountUSD = round.Amount + } + if round.Kind != "" { + ev.FundingType = round.Kind + } + events = append(events, ev) + } + } + return events +} + +func uniqueMembers(events []fundingEvent) []string { + seen := make(map[string]struct{}) + members := make([]string, 0) + for _, ev := range events { + name := strings.TrimSpace(ev.Member) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + members = append(members, name) + } + sort.Strings(members) + return members +} + +func membersJoinedByTier(ds *Dataset, tier string, year int) []string { + members := make([]string, 0) + for _, item := range ds.Items { + if !isMemberTier(item, tier) { + continue + } + if item.JoinedAt == nil || item.JoinedAt.Year() != year { + continue + } + members = append(members, item.Name) + } + sort.Strings(members) + return members +} + +// queryProjects filters and returns projects based on various criteria +func queryProjects(ds *Dataset, maturity, name, graduatedFrom, graduatedTo, incubatingFrom, incubatingTo, acceptedFrom, acceptedTo string, limit int) (string, error) { + type projectResult struct { + Name string `json:"name"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + Maturity string `json:"maturity,omitempty"` + AcceptedAt string `json:"accepted_at,omitempty"` + IncubatingAt string `json:"incubating_at,omitempty"` + GraduatedAt string `json:"graduated_at,omitempty"` + JoinedAt string `json:"joined_at,omitempty"` + } + + // Parse date filters + var gradFromDate, gradToDate, incFromDate, incToDate, accFromDate, accToDate *time.Time + + if graduatedFrom != "" { + d, err := time.Parse("2006-01-02", graduatedFrom) + if err != nil { + return "", fmt.Errorf("invalid graduated_from date: %w", err) + } + gradFromDate = &d + } + if graduatedTo != "" { + d, err := time.Parse("2006-01-02", graduatedTo) + if err != nil { + return "", fmt.Errorf("invalid graduated_to date: %w", err) + } + gradToDate = &d + } + if incubatingFrom != "" { + d, err := time.Parse("2006-01-02", incubatingFrom) + if err != nil { + return "", fmt.Errorf("invalid incubating_from date: %w", err) + } + incFromDate = &d + } + if incubatingTo != "" { + d, err := time.Parse("2006-01-02", incubatingTo) + if err != nil { + return "", fmt.Errorf("invalid incubating_to date: %w", err) + } + incToDate = &d + } + if acceptedFrom != "" { + d, err := time.Parse("2006-01-02", acceptedFrom) + if err != nil { + return "", fmt.Errorf("invalid accepted_from date: %w", err) + } + accFromDate = &d + } + if acceptedTo != "" { + d, err := time.Parse("2006-01-02", acceptedTo) + if err != nil { + return "", fmt.Errorf("invalid accepted_to date: %w", err) + } + accToDate = &d + } + + results := make([]projectResult, 0) + nameLower := strings.ToLower(name) + + for _, item := range ds.Items { + // Filter by maturity + if maturity != "" && !strings.EqualFold(item.Maturity, maturity) { + continue + } + + // Filter by name + if name != "" && !strings.Contains(strings.ToLower(item.Name), nameLower) { + continue + } + + // Filter by graduated dates + if gradFromDate != nil { + if item.GraduatedAt == nil || item.GraduatedAt.Before(*gradFromDate) { + continue + } + } + if gradToDate != nil { + if item.GraduatedAt == nil || item.GraduatedAt.After(*gradToDate) { + continue + } + } + + // Filter by incubating dates + if incFromDate != nil { + if item.IncubatingAt == nil || item.IncubatingAt.Before(*incFromDate) { + continue + } + } + if incToDate != nil { + if item.IncubatingAt == nil || item.IncubatingAt.After(*incToDate) { + continue + } + } + + // Filter by accepted dates + if accFromDate != nil { + if item.AcceptedAt == nil || item.AcceptedAt.Before(*accFromDate) { + continue + } + } + if accToDate != nil { + if item.AcceptedAt == nil || item.AcceptedAt.After(*accToDate) { + continue + } + } + + // Only include projects (items with maturity) + if item.Maturity == "" { + continue + } + + result := projectResult{ + Name: item.Name, + Category: item.Category, + Subcategory: item.Subcategory, + Maturity: item.Maturity, + } + if item.AcceptedAt != nil { + result.AcceptedAt = item.AcceptedAt.Format("2006-01-02") + } + if item.IncubatingAt != nil { + result.IncubatingAt = item.IncubatingAt.Format("2006-01-02") + } + if item.GraduatedAt != nil { + result.GraduatedAt = item.GraduatedAt.Format("2006-01-02") + } + if item.JoinedAt != nil { + result.JoinedAt = item.JoinedAt.Format("2006-01-02") + } + + results = append(results, result) + if len(results) >= limit { + break + } + } + + response := map[string]interface{}{ + "count": len(results), + "projects": results, + } + + data, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// queryMembers filters and returns members based on tier and join dates +func queryMembers(ds *Dataset, tier, joinedFrom, joinedTo string, limit int) (string, error) { + type memberResult struct { + Name string `json:"name"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + MemberSubcategory string `json:"member_subcategory,omitempty"` + JoinedAt string `json:"joined_at,omitempty"` + } + + // Parse date filters + var joinFromDate, joinToDate *time.Time + + if joinedFrom != "" { + d, err := time.Parse("2006-01-02", joinedFrom) + if err != nil { + return "", fmt.Errorf("invalid joined_from date: %w", err) + } + joinFromDate = &d + } + if joinedTo != "" { + d, err := time.Parse("2006-01-02", joinedTo) + if err != nil { + return "", fmt.Errorf("invalid joined_to date: %w", err) + } + joinToDate = &d + } + + results := make([]memberResult, 0) + + for _, item := range ds.Items { + // Only include members + if !strings.Contains(strings.ToLower(item.Category), "member") { + continue + } + + // Filter by tier + if tier != "" && !isMemberTier(item, tier) { + continue + } + + // Filter by joined dates + if joinFromDate != nil { + if item.JoinedAt == nil || item.JoinedAt.Before(*joinFromDate) { + continue + } + } + if joinToDate != nil { + if item.JoinedAt == nil || item.JoinedAt.After(*joinToDate) { + continue + } + } + + result := memberResult{ + Name: item.Name, + Category: item.Category, + Subcategory: item.Subcategory, + MemberSubcategory: item.MemberSubcategory, + } + if item.JoinedAt != nil { + result.JoinedAt = item.JoinedAt.Format("2006-01-02") + } + + results = append(results, result) + if len(results) >= limit { + break + } + } + + response := map[string]interface{}{ + "count": len(results), + "members": results, + } + + data, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// getProjectDetails returns detailed information about a specific project +func getProjectDetails(ds *Dataset, name string) (string, error) { + type projectDetail struct { + Name string `json:"name"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + Maturity string `json:"maturity,omitempty"` + AcceptedAt string `json:"accepted_at,omitempty"` + IncubatingAt string `json:"incubating_at,omitempty"` + GraduatedAt string `json:"graduated_at,omitempty"` + JoinedAt string `json:"joined_at,omitempty"` + } + + nameLower := strings.ToLower(name) + var matches []projectDetail + + for _, item := range ds.Items { + // Only include projects (items with maturity) + if item.Maturity == "" { + continue + } + + if strings.Contains(strings.ToLower(item.Name), nameLower) { + detail := projectDetail{ + Name: item.Name, + Category: item.Category, + Subcategory: item.Subcategory, + Maturity: item.Maturity, + } + if item.AcceptedAt != nil { + detail.AcceptedAt = item.AcceptedAt.Format("2006-01-02") + } + if item.IncubatingAt != nil { + detail.IncubatingAt = item.IncubatingAt.Format("2006-01-02") + } + if item.GraduatedAt != nil { + detail.GraduatedAt = item.GraduatedAt.Format("2006-01-02") + } + if item.JoinedAt != nil { + detail.JoinedAt = item.JoinedAt.Format("2006-01-02") + } + matches = append(matches, detail) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("no project found matching name: %s", name) + } + + response := map[string]interface{}{ + "count": len(matches), + "projects": matches, + } + + data, err := json.MarshalIndent(response, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/utilities/landscape-mcp-server/types.go b/utilities/landscape-mcp-server/types.go new file mode 100644 index 00000000..4f386939 --- /dev/null +++ b/utilities/landscape-mcp-server/types.go @@ -0,0 +1,37 @@ +package main + +import "time" + +// Dataset represents the in-memory data used by the MCP server. +type Dataset struct { + Items []LandscapeItem + CrunchbaseOrgs map[string]CrunchbaseOrganization +} + +// LandscapeItem captures a single entry in the landscape with the +// fields required for the MCP queries we support. +type LandscapeItem struct { + Name string + Category string + Subcategory string + MemberSubcategory string + Maturity string + JoinedAt *time.Time + AcceptedAt *time.Time + GraduatedAt *time.Time + IncubatingAt *time.Time + CrunchbaseURL string +} + +// CrunchbaseOrganization describes the subset of Crunchbase data the +// server needs to compute funding-based queries. +type CrunchbaseOrganization struct { + FundingRounds []FundingRound +} + +// FundingRound captures a single funding event. +type FundingRound struct { + Amount *int64 + AnnouncedOn *time.Time + Kind string +}