Skip to content

Commit 3e9794a

Browse files
gpeden-lfCopilot
andcommitted
feat: add get_project_summary tool and parent_uid filter on search_projects
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 <gpeden@linuxfoundation.org>
1 parent 342898d commit 3e9794a

4 files changed

Lines changed: 245 additions & 6 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ Before hitting **Connect**, follow the **Open Auth Settings** button, then selec
9797

9898
### Projects
9999

100-
| Tool | Description |
101-
|------|-------------|
102-
| `search_projects` | Search for LFX projects by name with typeahead and pagination |
103-
| `get_project` | Get a project's base info and settings by UID |
100+
| Tool | Description |
101+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
102+
| `search_projects` | Search for LFX projects by name; optionally filter by parent project UID to list child projects |
103+
| `get_project` | Get a project's base info and settings by UID |
104+
| `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 |
104105

105106
### Committees
106107

cmd/lfx-mcp-server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const errKey = "error"
7474
var defaultTools = []string{
7575
"search_projects",
7676
"get_project",
77+
"get_project_summary",
7778
"search_committees",
7879
"get_committee",
7980
"get_committee_member",
@@ -344,6 +345,9 @@ func newServer(cfg Config) *mcp.Server {
344345
if enabledTools["get_project"] {
345346
tools.RegisterGetProject(server)
346347
}
348+
if enabledTools["get_project_summary"] {
349+
tools.RegisterGetProjectSummary(server)
350+
}
347351
if enabledTools["search_committees"] {
348352
tools.RegisterSearchCommittees(server)
349353
}

internal/tools/project.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func SetProjectConfig(cfg *ProjectConfig) {
3737
func RegisterSearchProjects(server *mcp.Server) {
3838
AddToolWithScopes(server, &mcp.Tool{
3939
Name: "search_projects",
40-
Description: "Search for LFX projects by name using the LFX query service",
40+
Description: "Search for LFX projects by name using the LFX query service. Optionally filter by parent project UID to get sub-projects.",
4141
Annotations: &mcp.ToolAnnotations{
4242
Title: "Search Projects",
4343
ReadOnlyHint: true,
@@ -59,7 +59,8 @@ func RegisterGetProject(server *mcp.Server) {
5959

6060
// SearchProjectsArgs defines the input parameters for the search_projects tool.
6161
type SearchProjectsArgs struct {
62-
Name string `json:"name" jsonschema:"Name or partial name of the project to search for"`
62+
Name string `json:"name,omitempty" jsonschema:"Name or partial name of the project to search for"`
63+
ParentUID string `json:"parent_uid,omitempty" jsonschema:"Optional parent project UID to filter sub-projects (e.g. get all children of a foundation)"`
6364
PageSize int `json:"page_size,omitempty" jsonschema:"Number of results per page (default 10, max 100)"`
6465
PageToken string `json:"page_token,omitempty" jsonschema:"Opaque pagination token from a previous search response"`
6566
}
@@ -128,6 +129,11 @@ func handleSearchProjects(ctx context.Context, req *mcp.CallToolRequest, args Se
128129
payload.Name = &args.Name
129130
}
130131

132+
if args.ParentUID != "" {
133+
parent := "project:" + args.ParentUID
134+
payload.Parent = &parent
135+
}
136+
131137
if args.PageToken != "" {
132138
payload.PageToken = &args.PageToken
133139
}

internal/tools/project_summary.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright The Linux Foundation and contributors.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Package tools provides MCP tool implementations for the LFX MCP server.
5+
package tools
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"log/slog"
12+
"sync"
13+
14+
"github.com/linuxfoundation/lfx-mcp/internal/lfxv2"
15+
projectservice "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/project_service"
16+
querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc"
17+
"github.com/modelcontextprotocol/go-sdk/mcp"
18+
)
19+
20+
// RegisterGetProjectSummary registers the get_project_summary tool.
21+
func RegisterGetProjectSummary(server *mcp.Server) {
22+
AddToolWithScopes(server, &mcp.Tool{
23+
Name: "get_project_summary",
24+
Description: "Get a rolled-up fact sheet for an LFX project: child project count, " +
25+
"committee count, working group count, meeting count, plus base project metadata " +
26+
"(stage, legal entity type, formation date, funding model). " +
27+
"Useful for PMO complexity scoring and cost modeling.",
28+
Annotations: &mcp.ToolAnnotations{
29+
Title: "Get Project Summary",
30+
ReadOnlyHint: true,
31+
},
32+
}, ReadScopes(), handleGetProjectSummary)
33+
}
34+
35+
// GetProjectSummaryArgs defines input parameters for get_project_summary.
36+
type GetProjectSummaryArgs struct {
37+
UID string `json:"uid" jsonschema:"The v2 UID of the project to retrieve dimensions for"`
38+
}
39+
40+
// ProjectSummary is the rolled-up fact sheet returned by get_project_summary.
41+
type ProjectSummary struct {
42+
UID *string `json:"uid,omitempty"`
43+
Name *string `json:"name,omitempty"`
44+
Stage *string `json:"stage,omitempty"`
45+
LegalEntityType *string `json:"legal_entity_type,omitempty"`
46+
FormationDate *string `json:"formation_date,omitempty"`
47+
FundingModel []string `json:"funding_model,omitempty"`
48+
ChildProjectCount *uint64 `json:"child_project_count,omitempty"`
49+
CommitteeCount *uint64 `json:"committee_count,omitempty"`
50+
WorkingGroupCount *uint64 `json:"working_group_count,omitempty"`
51+
MeetingCount *uint64 `json:"meeting_count,omitempty"`
52+
Warnings []string `json:"warnings,omitempty"`
53+
}
54+
55+
// handleGetProjectSummary returns a rolled-up complexity fact sheet for a project.
56+
func handleGetProjectSummary(ctx context.Context, req *mcp.CallToolRequest, args GetProjectSummaryArgs) (*mcp.CallToolResult, any, error) {
57+
logger := slog.New(mcp.NewLoggingHandler(req.Session, nil))
58+
59+
if projectConfig == nil {
60+
return errorResult("project tools not configured"), nil, nil
61+
}
62+
63+
if args.UID == "" {
64+
return errorResult("uid is required"), nil, nil
65+
}
66+
67+
mcpToken, err := lfxv2.ExtractMCPToken(req.Extra.TokenInfo)
68+
if err != nil {
69+
return errorResult(fmt.Sprintf("failed to extract MCP token: %v", err)), nil, nil
70+
}
71+
72+
ctx = lfxv2.WithMCPToken(ctx, mcpToken)
73+
74+
clients, err := lfxv2.NewClients(ctx, lfxv2.ClientConfig{
75+
APIDomain: projectConfig.LFXAPIURL,
76+
TokenExchangeClient: projectConfig.TokenExchangeClient,
77+
DebugLogger: projectConfig.DebugLogger,
78+
})
79+
if err != nil {
80+
return errorResult(fmt.Sprintf("failed to connect to LFX API: %s", lfxv2.ErrorMessage(err))), nil, nil
81+
}
82+
83+
parent := "project:" + args.UID
84+
version := "1"
85+
86+
childType := projectResourceType
87+
commType := committeeResourceType
88+
mtgType := meetingResourceType
89+
90+
var (
91+
childCount *querysvc.QueryResourcesCountResult
92+
committeeCount *querysvc.QueryResourcesCountResult
93+
wgCount *querysvc.QueryResourcesCountResult
94+
meetingCount *querysvc.QueryResourcesCountResult
95+
mu sync.Mutex
96+
warnings []string
97+
wg sync.WaitGroup
98+
)
99+
100+
addWarning := func(msg string) {
101+
mu.Lock()
102+
warnings = append(warnings, msg)
103+
mu.Unlock()
104+
}
105+
106+
wg.Add(4)
107+
108+
go func() {
109+
defer wg.Done()
110+
res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{
111+
Version: version, Type: &childType, Parent: &parent,
112+
})
113+
if err != nil {
114+
logger.Warn("failed to count child projects", "error", lfxv2.ErrorMessage(err))
115+
addWarning("child_project_count unavailable: " + lfxv2.ErrorMessage(err))
116+
return
117+
}
118+
mu.Lock()
119+
childCount = res
120+
mu.Unlock()
121+
}()
122+
123+
go func() {
124+
defer wg.Done()
125+
res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{
126+
Version: version, Type: &commType, Parent: &parent,
127+
})
128+
if err != nil {
129+
logger.Warn("failed to count committees", "error", lfxv2.ErrorMessage(err))
130+
addWarning("committee_count unavailable: " + lfxv2.ErrorMessage(err))
131+
return
132+
}
133+
mu.Lock()
134+
committeeCount = res
135+
mu.Unlock()
136+
}()
137+
138+
go func() {
139+
defer wg.Done()
140+
res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{
141+
Version: version, Type: &commType, Parent: &parent,
142+
Filters: []string{"category:Working Group"},
143+
})
144+
if err != nil {
145+
logger.Warn("failed to count working groups", "error", lfxv2.ErrorMessage(err))
146+
addWarning("working_group_count unavailable: " + lfxv2.ErrorMessage(err))
147+
return
148+
}
149+
mu.Lock()
150+
wgCount = res
151+
mu.Unlock()
152+
}()
153+
154+
go func() {
155+
defer wg.Done()
156+
res, err := clients.QuerySvc.QueryResourcesCount(ctx, &querysvc.QueryResourcesCountPayload{
157+
Version: version, Type: &mtgType, Parent: &parent,
158+
})
159+
if err != nil {
160+
logger.Warn("failed to count meetings", "error", lfxv2.ErrorMessage(err))
161+
addWarning("meeting_count unavailable: " + lfxv2.ErrorMessage(err))
162+
return
163+
}
164+
mu.Lock()
165+
meetingCount = res
166+
mu.Unlock()
167+
}()
168+
169+
wg.Wait()
170+
171+
// Get base project info
172+
baseResult, err := clients.Project.GetOneProjectBase(ctx, &projectservice.GetOneProjectBasePayload{
173+
UID: &args.UID,
174+
})
175+
if err != nil {
176+
return errorResult(fmt.Sprintf("failed to get project: %s", lfxv2.ErrorMessage(err))), nil, nil
177+
}
178+
179+
p := baseResult.Project
180+
dims := ProjectSummary{
181+
UID: p.UID,
182+
Name: p.Name,
183+
Stage: p.Stage,
184+
LegalEntityType: p.LegalEntityType,
185+
FundingModel: p.FundingModel,
186+
FormationDate: p.FormationDate,
187+
Warnings: warnings,
188+
}
189+
190+
if childCount != nil {
191+
dims.ChildProjectCount = &childCount.Count
192+
}
193+
194+
if committeeCount != nil {
195+
dims.CommitteeCount = &committeeCount.Count
196+
}
197+
198+
if wgCount != nil {
199+
dims.WorkingGroupCount = &wgCount.Count
200+
}
201+
202+
if meetingCount != nil {
203+
dims.MeetingCount = &meetingCount.Count
204+
}
205+
206+
prettyJSON, err := json.MarshalIndent(dims, "", " ")
207+
if err != nil {
208+
return errorResult(fmt.Sprintf("failed to format result: %v", err)), nil, nil
209+
}
210+
211+
logger.Info("get_project_summary succeeded", "uid", args.UID)
212+
213+
return &mcp.CallToolResult{
214+
Content: []mcp.Content{
215+
&mcp.TextContent{Text: string(prettyJSON)},
216+
},
217+
}, nil, nil
218+
}
219+
220+
// errorResult is a convenience helper for returning a tool error response.
221+
func errorResult(msg string) *mcp.CallToolResult {
222+
return &mcp.CallToolResult{
223+
Content: []mcp.Content{
224+
&mcp.TextContent{Text: "Error: " + msg},
225+
},
226+
IsError: true,
227+
}
228+
}

0 commit comments

Comments
 (0)