From be91430294e6f917a4309b569d9d01539874c7f1 Mon Sep 17 00:00:00 2001 From: George Peden Date: Mon, 23 Mar 2026 11:22:49 -0700 Subject: [PATCH 1/3] feat: add get_project_summary tool and parent_uid filter on search_projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new get_project_summary tool that returns a rolled-up fact sheet for a project: child project count, committee count, working group count, meeting count, plus base metadata. First tool in the codebase to use QueryResourcesCount. Also adds parent_uid parameter to search_projects to support filtering child projects under a given foundation or umbrella project UID (ref ARCH-364). Submitting per Eric Searcy's recommendation given dev access blockers (LFXV2-1318) — LFX team to verify end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: George Peden --- README.md | 9 +- cmd/lfx-mcp-server/main.go | 4 + internal/tools/project_summary.go | 228 ++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 internal/tools/project_summary.go diff --git a/README.md b/README.md index dfeae85..e81cb5d 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,11 @@ Before hitting **Connect**, follow the **Open Auth Settings** button, then selec ### 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 80f313a..2d1ec53 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", @@ -630,6 +631,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/project_summary.go b/internal/tools/project_summary.go new file mode 100644 index 0000000..190a42c --- /dev/null +++ b/internal/tools/project_summary.go @@ -0,0 +1,228 @@ +// 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" + "log/slog" + "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) { + AddToolWithScopes(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, + }, + }, ReadScopes(), 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 := slog.New(mcp.NewLoggingHandler(req.Session, nil)) + + if projectConfig == nil { + return errorResult("project tools not configured"), nil, nil + } + + if args.UID == "" { + return errorResult("uid is required"), nil, nil + } + + mcpToken, err := lfxv2.ExtractMCPToken(req.Extra.TokenInfo) + if err != nil { + return errorResult(fmt.Sprintf("failed to extract MCP token: %v", err)), nil, nil + } + + ctx = lfxv2.WithMCPToken(ctx, mcpToken) + + clients, err := lfxv2.NewClients(ctx, lfxv2.ClientConfig{ + APIDomain: projectConfig.LFXAPIURL, + TokenExchangeClient: projectConfig.TokenExchangeClient, + DebugLogger: projectConfig.DebugLogger, + }) + if err != nil { + return errorResult(fmt.Sprintf("failed to connect to LFX API: %s", lfxv2.ErrorMessage(err))), nil, nil + } + + 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.Warn("failed to count child projects", "error", lfxv2.ErrorMessage(err)) + addWarning("child_project_count unavailable: " + lfxv2.ErrorMessage(err)) + 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.Warn("failed to count committees", "error", lfxv2.ErrorMessage(err)) + addWarning("committee_count unavailable: " + lfxv2.ErrorMessage(err)) + 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.Warn("failed to count working groups", "error", lfxv2.ErrorMessage(err)) + addWarning("working_group_count unavailable: " + lfxv2.ErrorMessage(err)) + 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.Warn("failed to count meetings", "error", lfxv2.ErrorMessage(err)) + addWarning("meeting_count unavailable: " + lfxv2.ErrorMessage(err)) + 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 { + return errorResult(fmt.Sprintf("failed to get project: %s", lfxv2.ErrorMessage(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 { + return errorResult(fmt.Sprintf("failed to format result: %v", err)), nil, nil + } + + logger.Info("get_project_summary succeeded", "uid", args.UID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(prettyJSON)}, + }, + }, nil, nil +} + +// 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, + } +} From 20288347c09ded3c7a35d0a872efbc7ca420df41 Mon Sep 17 00:00:00 2001 From: George Peden Date: Wed, 29 Apr 2026 09:43:46 -0600 Subject: [PATCH 2/3] fix: update project_summary to use current tool API patterns - Replace AddToolWithScopes/ReadScopes with mcp.AddTool - Use projectConfig.Clients directly instead of lfxv2.NewClients - Replace lfxv2.ErrorMessage with err.Error() - Move errorResult helper to helpers.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: George Peden --- internal/tools/helpers.go | 10 +++++++ internal/tools/project_summary.go | 44 ++++++++++--------------------- 2 files changed, 24 insertions(+), 30 deletions(-) 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 index 190a42c..edc6b93 100644 --- a/internal/tools/project_summary.go +++ b/internal/tools/project_summary.go @@ -19,7 +19,7 @@ import ( // RegisterGetProjectSummary registers the get_project_summary tool. func RegisterGetProjectSummary(server *mcp.Server) { - AddToolWithScopes(server, &mcp.Tool{ + 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 " + @@ -29,7 +29,7 @@ func RegisterGetProjectSummary(server *mcp.Server) { Title: "Get Project Summary", ReadOnlyHint: true, }, - }, ReadScopes(), handleGetProjectSummary) + }, handleGetProjectSummary) } // GetProjectSummaryArgs defines input parameters for get_project_summary. @@ -69,16 +69,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args return errorResult(fmt.Sprintf("failed to extract MCP token: %v", err)), nil, nil } - ctx = lfxv2.WithMCPToken(ctx, mcpToken) - - clients, err := lfxv2.NewClients(ctx, lfxv2.ClientConfig{ - APIDomain: projectConfig.LFXAPIURL, - TokenExchangeClient: projectConfig.TokenExchangeClient, - DebugLogger: projectConfig.DebugLogger, - }) - if err != nil { - return errorResult(fmt.Sprintf("failed to connect to LFX API: %s", lfxv2.ErrorMessage(err))), nil, nil - } + ctx = projectConfig.Clients.WithMCPToken(ctx, mcpToken) + clients := projectConfig.Clients parent := "project:" + args.UID version := "1" @@ -111,8 +103,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &childType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count child projects", "error", lfxv2.ErrorMessage(err)) - addWarning("child_project_count unavailable: " + lfxv2.ErrorMessage(err)) + logger.Warn("failed to count child projects", "error", err.Error()) + addWarning("child_project_count unavailable: " + err.Error()) return } mu.Lock() @@ -126,8 +118,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &commType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count committees", "error", lfxv2.ErrorMessage(err)) - addWarning("committee_count unavailable: " + lfxv2.ErrorMessage(err)) + logger.Warn("failed to count committees", "error", err.Error()) + addWarning("committee_count unavailable: " + err.Error()) return } mu.Lock() @@ -142,8 +134,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Filters: []string{"category:Working Group"}, }) if err != nil { - logger.Warn("failed to count working groups", "error", lfxv2.ErrorMessage(err)) - addWarning("working_group_count unavailable: " + lfxv2.ErrorMessage(err)) + logger.Warn("failed to count working groups", "error", err.Error()) + addWarning("working_group_count unavailable: " + err.Error()) return } mu.Lock() @@ -157,8 +149,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &mtgType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count meetings", "error", lfxv2.ErrorMessage(err)) - addWarning("meeting_count unavailable: " + lfxv2.ErrorMessage(err)) + logger.Warn("failed to count meetings", "error", err.Error()) + addWarning("meeting_count unavailable: " + err.Error()) return } mu.Lock() @@ -173,7 +165,7 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args UID: &args.UID, }) if err != nil { - return errorResult(fmt.Sprintf("failed to get project: %s", lfxv2.ErrorMessage(err))), nil, nil + return errorResult(fmt.Sprintf("failed to get project: %s", err.Error())), nil, nil } p := baseResult.Project @@ -217,12 +209,4 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args }, nil, nil } -// 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, - } -} + From 542f6c62c0c526a4c888956892207277bc9defe4 Mon Sep 17 00:00:00 2001 From: George Peden Date: Wed, 29 Apr 2026 11:57:24 -0600 Subject: [PATCH 3/3] fix: align project summary tool patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: George Peden --- internal/tools/project_summary.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/tools/project_summary.go b/internal/tools/project_summary.go index edc6b93..a9a517a 100644 --- a/internal/tools/project_summary.go +++ b/internal/tools/project_summary.go @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "fmt" - "log/slog" "sync" "github.com/linuxfoundation/lfx-mcp/internal/lfxv2" @@ -54,18 +53,21 @@ type ProjectSummary struct { // 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 := slog.New(mcp.NewLoggingHandler(req.Session, nil)) + 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 } @@ -103,7 +105,7 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &childType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count child projects", "error", err.Error()) + logger.WarnContext(ctx, "failed to count child projects", "error", err) addWarning("child_project_count unavailable: " + err.Error()) return } @@ -118,7 +120,7 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &commType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count committees", "error", err.Error()) + logger.WarnContext(ctx, "failed to count committees", "error", err) addWarning("committee_count unavailable: " + err.Error()) return } @@ -134,7 +136,7 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Filters: []string{"category:Working Group"}, }) if err != nil { - logger.Warn("failed to count working groups", "error", err.Error()) + logger.WarnContext(ctx, "failed to count working groups", "error", err) addWarning("working_group_count unavailable: " + err.Error()) return } @@ -149,7 +151,7 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args Version: version, Type: &mtgType, Parent: &parent, }) if err != nil { - logger.Warn("failed to count meetings", "error", err.Error()) + logger.WarnContext(ctx, "failed to count meetings", "error", err) addWarning("meeting_count unavailable: " + err.Error()) return } @@ -165,7 +167,8 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args UID: &args.UID, }) if err != nil { - return errorResult(fmt.Sprintf("failed to get project: %s", err.Error())), nil, nil + logger.ErrorContext(ctx, "GetOneProjectBase failed", "error", err, "uid", args.UID) + return errorResult(friendlyAPIError("failed to get project", err)), nil, nil } p := baseResult.Project @@ -197,10 +200,11 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args 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.Info("get_project_summary succeeded", "uid", args.UID) + logger.InfoContext(ctx, "get_project_summary succeeded", "uid", args.UID) return &mcp.CallToolResult{ Content: []mcp.Content{ @@ -209,4 +213,3 @@ func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args }, nil, nil } -