Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions cmd/lfx-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions internal/tools/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
215 changes: 215 additions & 0 deletions internal/tools/project_summary.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +20 to +32

// 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)

Comment on lines +55 to +57
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
Comment on lines +74 to +80
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,
Comment on lines +175 to +180
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
}

Loading