diff --git a/README.md b/README.md index 9e42d27..90ac0e6 100644 --- a/README.md +++ b/README.md @@ -200,10 +200,11 @@ Hitting **Connect** will open a browser window for LFID login. ### Projects -| Tool | Description | -|-------------------|---------------------------------------------------------------| -| `search_projects` | Search for LFX projects by name with typeahead and pagination | -| `get_project` | Get a project's base info and settings by UID | +| Tool | Description | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `search_projects` | Search for LFX projects by name; optionally filter by parent project UID to list child projects | +| `get_project` | Get a project's base info and settings by UID | +| `get_project_summary` | Get a rolled-up fact sheet for a project: child project count, committee count, working group count, meeting count, plus base metadata | ### Committees diff --git a/cmd/lfx-mcp-server/main.go b/cmd/lfx-mcp-server/main.go index 7321e89..0346774 100644 --- a/cmd/lfx-mcp-server/main.go +++ b/cmd/lfx-mcp-server/main.go @@ -118,6 +118,7 @@ var groupToCommitteeToolNames = func() map[string]string { var defaultTools = []string{ "search_projects", "get_project", + "get_project_summary", "search_committees", "get_committee", "get_committee_member", @@ -665,6 +666,9 @@ func newServer(cfg Config, serviceName string, callerToken *auth.TokenInfo) *mcp if enabledTools["get_project"] && canRead { tools.RegisterGetProject(server) } + if enabledTools["get_project_summary"] && canRead { + tools.RegisterGetProjectSummary(server) + } if enabledTools["search_committees"] && canRead { tools.RegisterSearchCommittees(server, cfg.CommitteesAsGroups) } diff --git a/internal/tools/helpers.go b/internal/tools/helpers.go index a433776..45c9cd3 100644 --- a/internal/tools/helpers.go +++ b/internal/tools/helpers.go @@ -123,3 +123,13 @@ func friendlyAPIError(op string, err error) string { } return op + ": " + err.Error() } + +// errorResult is a convenience helper for returning a tool error response. +func errorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Error: " + msg}, + }, + IsError: true, + } +} diff --git a/internal/tools/project_summary.go b/internal/tools/project_summary.go new file mode 100644 index 0000000..a9a517a --- /dev/null +++ b/internal/tools/project_summary.go @@ -0,0 +1,215 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package tools provides MCP tool implementations for the LFX MCP server. +package tools + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/linuxfoundation/lfx-mcp/internal/lfxv2" + projectservice "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service" + querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RegisterGetProjectSummary registers the get_project_summary tool. +func RegisterGetProjectSummary(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "get_project_summary", + Description: "Get a rolled-up fact sheet for an LFX project: child project count, " + + "committee count, working group count, meeting count, plus base project metadata " + + "(stage, legal entity type, formation date, funding model). " + + "Useful for PMO complexity scoring and cost modeling.", + Annotations: &mcp.ToolAnnotations{ + Title: "Get Project Summary", + ReadOnlyHint: true, + }, + }, handleGetProjectSummary) +} + +// GetProjectSummaryArgs defines input parameters for get_project_summary. +type GetProjectSummaryArgs struct { + UID string `json:"uid" jsonschema:"The v2 UID of the project to retrieve dimensions for"` +} + +// ProjectSummary is the rolled-up fact sheet returned by get_project_summary. +type ProjectSummary struct { + UID *string `json:"uid,omitempty"` + Name *string `json:"name,omitempty"` + Stage *string `json:"stage,omitempty"` + LegalEntityType *string `json:"legal_entity_type,omitempty"` + FormationDate *string `json:"formation_date,omitempty"` + FundingModel []string `json:"funding_model,omitempty"` + ChildProjectCount *uint64 `json:"child_project_count,omitempty"` + CommitteeCount *uint64 `json:"committee_count,omitempty"` + WorkingGroupCount *uint64 `json:"working_group_count,omitempty"` + MeetingCount *uint64 `json:"meeting_count,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// handleGetProjectSummary returns a rolled-up complexity fact sheet for a project. +func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args GetProjectSummaryArgs) (*mcp.CallToolResult, any, error) { + logger := newToolLogger(ctx, req) + + if projectConfig == nil { + logger.ErrorContext(ctx, "project tools not configured") + return errorResult("project tools not configured"), nil, nil + } + + if args.UID == "" { + logger.ErrorContext(ctx, "uid is required") + return errorResult("uid is required"), nil, nil + } + + mcpToken, err := lfxv2.ExtractMCPToken(req.Extra.TokenInfo) + if err != nil { + logger.ErrorContext(ctx, "failed to extract MCP token", "error", err) + return errorResult(fmt.Sprintf("failed to extract MCP token: %v", err)), nil, nil + } + + ctx = projectConfig.Clients.WithMCPToken(ctx, mcpToken) + clients := projectConfig.Clients + + parent := "project:" + args.UID + version := "1" + + childType := projectResourceType + commType := committeeResourceType + mtgType := meetingResourceType + + var ( + childCount *querysvc.QueryResourcesCountResult + committeeCount *querysvc.QueryResourcesCountResult + wgCount *querysvc.QueryResourcesCountResult + meetingCount *querysvc.QueryResourcesCountResult + mu sync.Mutex + warnings []string + wg sync.WaitGroup + ) + + addWarning := func(msg string) { + mu.Lock() + warnings = append(warnings, msg) + mu.Unlock() + } + + wg.Add(4) + + go func() { + defer wg.Done() + res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{ + Version: version, Type: &childType, Parent: &parent, + }) + if err != nil { + logger.WarnContext(ctx, "failed to count child projects", "error", err) + addWarning("child_project_count unavailable: " + err.Error()) + return + } + mu.Lock() + childCount = res + mu.Unlock() + }() + + go func() { + defer wg.Done() + res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{ + Version: version, Type: &commType, Parent: &parent, + }) + if err != nil { + logger.WarnContext(ctx, "failed to count committees", "error", err) + addWarning("committee_count unavailable: " + err.Error()) + return + } + mu.Lock() + committeeCount = res + mu.Unlock() + }() + + go func() { + defer wg.Done() + res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{ + Version: version, Type: &commType, Parent: &parent, + Filters: []string{"category:Working Group"}, + }) + if err != nil { + logger.WarnContext(ctx, "failed to count working groups", "error", err) + addWarning("working_group_count unavailable: " + err.Error()) + return + } + mu.Lock() + wgCount = res + mu.Unlock() + }() + + go func() { + defer wg.Done() + res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{ + Version: version, Type: &mtgType, Parent: &parent, + }) + if err != nil { + logger.WarnContext(ctx, "failed to count meetings", "error", err) + addWarning("meeting_count unavailable: " + err.Error()) + return + } + mu.Lock() + meetingCount = res + mu.Unlock() + }() + + wg.Wait() + + // Get base project info + baseResult, err := clients.Project.GetOneProjectBase(ctx, &projectservice.GetOneProjectBasePayload{ + UID: &args.UID, + }) + if err != nil { + logger.ErrorContext(ctx, "GetOneProjectBase failed", "error", err, "uid", args.UID) + return errorResult(friendlyAPIError("failed to get project", err)), nil, nil + } + + p := baseResult.Project + dims := ProjectSummary{ + UID: p.UID, + Name: p.Name, + Stage: p.Stage, + LegalEntityType: p.LegalEntityType, + FundingModel: p.FundingModel, + FormationDate: p.FormationDate, + Warnings: warnings, + } + + if childCount != nil { + dims.ChildProjectCount = &childCount.Count + } + + if committeeCount != nil { + dims.CommitteeCount = &committeeCount.Count + } + + if wgCount != nil { + dims.WorkingGroupCount = &wgCount.Count + } + + if meetingCount != nil { + dims.MeetingCount = &meetingCount.Count + } + + prettyJSON, err := json.MarshalIndent(dims, "", " ") + if err != nil { + logger.ErrorContext(ctx, "failed to marshal project summary", "error", err, "uid", args.UID) + return errorResult(fmt.Sprintf("failed to format result: %v", err)), nil, nil + } + + logger.InfoContext(ctx, "get_project_summary succeeded", "uid", args.UID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(prettyJSON)}, + }, + }, nil, nil +} +