Skip to content
Merged
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
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/linuxfoundation/lfx-v2-committee-service v0.4.0
github.com/linuxfoundation/lfx-v2-mailing-list-service v0.5.0
github.com/linuxfoundation/lfx-v2-meeting-service v0.11.8
github.com/linuxfoundation/lfx-v2-member-service v0.9.0
github.com/linuxfoundation/lfx-v2-project-service v0.8.0
github.com/linuxfoundation/lfx-v2-query-service v0.4.22
Expand Down Expand Up @@ -67,9 +68,9 @@ require (
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
14 changes: 8 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ github.com/linuxfoundation/lfx-v2-committee-service v0.4.0 h1:KOqG9brs0XV6Yd71cb
github.com/linuxfoundation/lfx-v2-committee-service v0.4.0/go.mod h1:QZtzJQpcPOLuVcTo3+3Crcn0agAsj53CSe8Bh9odIlU=
github.com/linuxfoundation/lfx-v2-mailing-list-service v0.5.0 h1:YXPkGBIP7BKLn28UiLZAD9JrmHcs8rGI9yUBEALgypc=
github.com/linuxfoundation/lfx-v2-mailing-list-service v0.5.0/go.mod h1:3Sktz+IJy1oQvqgg4pgvx+/JqjLDKuwL4vJIuM3LID0=
github.com/linuxfoundation/lfx-v2-meeting-service v0.11.8 h1:yR84cNCxhr6rFAijyIBIhI6yr+mTVMy0GlxJbPf9Yww=
github.com/linuxfoundation/lfx-v2-meeting-service v0.11.8/go.mod h1:92vgeHEsvlcaOMhsWXrZT/NYwzWC9lE9jgxKpwDTH9E=
github.com/linuxfoundation/lfx-v2-member-service v0.9.0 h1:+nWe1+vSBGa7taYhfaAwwi0eX0eWIrbJcXFG37egvfg=
github.com/linuxfoundation/lfx-v2-member-service v0.9.0/go.mod h1:Q6VQbl9DjxQMEfjWaoJ+yr4m1BBV50586bvfrVJ2sX8=
github.com/linuxfoundation/lfx-v2-project-service v0.8.0 h1:lGYJ4NqUjykw4V6cvTUFUGrUkliqySI4K/iT9PGTpsc=
Expand Down Expand Up @@ -140,18 +142,18 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
65 changes: 65 additions & 0 deletions internal/lfxv2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import (
committeehttpclient "github.com/linuxfoundation/lfx-v2-committee-service/gen/http/committee_service/client"
mailinglisthttpclient "github.com/linuxfoundation/lfx-v2-mailing-list-service/gen/http/mailing_list/client"
mailinglist "github.com/linuxfoundation/lfx-v2-mailing-list-service/gen/mailing_list"
meetinghttpclient "github.com/linuxfoundation/lfx-v2-meeting-service/gen/http/meeting_service/client"
meetingservice "github.com/linuxfoundation/lfx-v2-meeting-service/gen/meeting_service"
memberhttpclient "github.com/linuxfoundation/lfx-v2-member-service/gen/http/membership_service/client"
memberservice "github.com/linuxfoundation/lfx-v2-member-service/gen/membership_service"
projecthttpclient "github.com/linuxfoundation/lfx-v2-project-service/api/project/v1/gen/http/project_service/client"
Expand Down Expand Up @@ -137,6 +139,7 @@ type ClientConfig struct {
type Clients struct {
Committee *committeeservice.Client
MailingList *mailinglist.Client
Meeting *meetingservice.Client
Member *memberservice.Client
Project *projectservice.Client
QuerySvc *querysvc.Client
Expand Down Expand Up @@ -276,6 +279,68 @@ func NewClients(_ context.Context, cfg ClientConfig) (*Clients, error) {
mlHTTPClient.GetGroupsioArtifactDownload(),
)

// Initialize meeting service client.
// The meeting service exposes root-level paths (/itx/past_meetings/, etc.) with no
// path prefix, so use cfg.APIDomain directly — identical to the member client.
meetingURL, err := url.Parse(cfg.APIDomain)
if err != nil {
return nil, fmt.Errorf("failed to parse meeting service URL: %w", err)
}

meetingHTTPClient := meetinghttpclient.NewClient(
meetingURL.Scheme,
meetingURL.Host,
httpClient,
goahttp.RequestEncoder,
goahttp.ResponseDecoder,
false,
)

// The generated meeting-service constructor requires every endpoint. We only
// call GetItxPastMeeting today; the rest are passed to satisfy the signature.
clients.Meeting = meetingservice.NewClient(
meetingHTTPClient.Readyz(),
meetingHTTPClient.Livez(),
meetingHTTPClient.CreateItxMeeting(),
meetingHTTPClient.GetItxMeeting(),
meetingHTTPClient.DeleteItxMeeting(),
meetingHTTPClient.UpdateItxMeeting(),
meetingHTTPClient.GetItxMeetingCount(),
meetingHTTPClient.CreateItxRegistrant(),
meetingHTTPClient.GetItxRegistrant(),
meetingHTTPClient.UpdateItxRegistrant(),
meetingHTTPClient.DeleteItxRegistrant(),
meetingHTTPClient.GetItxJoinLink(),
meetingHTTPClient.GetItxRegistrantIcs(),
meetingHTTPClient.ResendItxRegistrantInvitation(),
meetingHTTPClient.ResendItxMeetingInvitations(),
meetingHTTPClient.RegisterItxCommitteeMembers(),
meetingHTTPClient.UpdateItxOccurrence(),
meetingHTTPClient.DeleteItxOccurrence(),
meetingHTTPClient.SubmitItxMeetingResponse(),
meetingHTTPClient.CreateItxPastMeeting(),
meetingHTTPClient.GetItxPastMeeting(),
meetingHTTPClient.DeleteItxPastMeeting(),
meetingHTTPClient.UpdateItxPastMeeting(),
meetingHTTPClient.GetItxPastMeetingSummary(),
meetingHTTPClient.UpdateItxPastMeetingSummary(),
meetingHTTPClient.CreateItxPastMeetingParticipant(),
meetingHTTPClient.UpdateItxPastMeetingParticipant(),
meetingHTTPClient.DeleteItxPastMeetingParticipant(),
meetingHTTPClient.CreateItxMeetingAttachment(),
meetingHTTPClient.GetItxMeetingAttachment(),
meetingHTTPClient.UpdateItxMeetingAttachment(),
meetingHTTPClient.DeleteItxMeetingAttachment(),
meetingHTTPClient.CreateItxMeetingAttachmentPresign(),
meetingHTTPClient.GetItxMeetingAttachmentDownload(),
meetingHTTPClient.CreateItxPastMeetingAttachment(),
meetingHTTPClient.GetItxPastMeetingAttachment(),
meetingHTTPClient.UpdateItxPastMeetingAttachment(),
meetingHTTPClient.DeleteItxPastMeetingAttachment(),
meetingHTTPClient.CreateItxPastMeetingAttachmentPresign(),
meetingHTTPClient.GetItxPastMeetingAttachmentDownload(),
)

// Initialize member service client.
// The member service exposes root-level paths (/b2b_orgs/, /project_memberships/,
// /key_contacts/) with no path prefix, so use cfg.APIDomain directly — identical
Expand Down
156 changes: 152 additions & 4 deletions internal/tools/meeting.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"

"github.com/linuxfoundation/lfx-mcp/internal/lfxv2"
meetingservice "github.com/linuxfoundation/lfx-v2-meeting-service/gen/meeting_service"
querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
Expand All @@ -29,6 +30,12 @@ const pastMeetingSummaryResourceType = "v1_past_meeting_summary"
// pastMeetingResourceType is the resource type filter for past meeting queries.
const pastMeetingResourceType = "v1_past_meeting"

// pastMeetingRecordingResourceType is the resource type filter for past meeting recording queries.
const pastMeetingRecordingResourceType = "v1_past_meeting_recording"

// pastMeetingTranscriptResourceType is the resource type filter for past meeting transcript queries.
const pastMeetingTranscriptResourceType = "v1_past_meeting_transcript"

// MeetingConfig holds configuration shared by meeting tools.
type MeetingConfig struct {
// Clients is the shared LFX v2 API client instance. It must be created once
Expand Down Expand Up @@ -197,7 +204,7 @@ func RegisterSearchPastMeetings(server *mcp.Server, asGroups bool) {
func RegisterGetPastMeeting(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "get_past_meeting",
Description: "Get an LFX past meeting by its UID using the query service.",
Description: "Get an LFX past meeting by its UID, including nested recording and transcript sub-objects (with links) when available. The recording or transcript is omitted if absent or inaccessible to the caller.",
Annotations: &mcp.ToolAnnotations{
Title: "Get Past Meeting",
ReadOnlyHint: true,
Expand Down Expand Up @@ -1254,7 +1261,148 @@ func handleSearchPastMeetings(ctx context.Context, req *mcp.CallToolRequest, arg
return &mcp.CallToolResult{Content: content}, nil, nil
}

// handleGetPastMeeting implements the get_past_meeting tool logic.
func handleGetPastMeeting(ctx context.Context, req *mcp.CallToolRequest, args GetPastMeetingArgs) (*mcp.CallToolResult, any, error) {
return handleGetPastMeetingResource(ctx, req, pastMeetingResourceType, "past meeting", args.UID)
// pastMeetingGetResult is the output type for the get_past_meeting tool. It nests
// the base past meeting alongside its recording and transcript, mirroring the
// shape of get_project's { base, settings }. The recording and transcript are
// omitted when absent or inaccessible.
type pastMeetingGetResult struct {
Meeting *meetingservice.ITXPastZoomMeeting `json:"meeting"`
Recording *querysvc.Resource `json:"recording,omitempty"`
Transcript *querysvc.Resource `json:"transcript,omitempty"`
}

// fetchPastMeetingChildResource fetches a single child resource (recording or
// transcript) of a past meeting from the query service, scoped by the parent
// reference "past_meeting:<meeting_and_occurrence_id>".
//
// NOTE: This is a non-idiomatic stand-in for a real "get by ID" call. Unlike the
// base past-meeting object — fetched via the meeting-service GetItxPastMeeting Goa
// endpoint — v1_past_meeting_recording and v1_past_meeting_transcript have no
// dedicated meeting-service get endpoint today, so we filter the query-service
// index by parent ref instead. A direct meeting-service Itx get endpoint for these
// two resource types would be preferable and should be a follow-up.
//
// Returns the resource when present, or (nil, nil) when absent.
func fetchPastMeetingChildResource(ctx context.Context, clients *lfxv2.Clients, resourceType, parentRef string) (*querysvc.Resource, error) {
payload := &querysvc.QueryResourcesPayload{
Version: "1",
Type: &resourceType,
Parent: &parentRef,
PageSize: 1,
Sort: "name_asc",

@emsearcy emsearcy Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted: Sort: "name_asc" here only matters if more than one result can come back for a given past_meeting:<id> parent ref — otherwise it is dead weight. If the code does anticipate >1 recording/transcript per past meeting, "first alphabetically by name" is an arbitrary tie-breaker (should be recency or size, not name).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I don't think Query Service supports a "no sort" option. There will always be a sort, even if we didn't include it here, so it's not like a performance optimization.

}

result, err := clients.QuerySvc.QueryResources(ctx, payload)
if err != nil {
return nil, err
}
if len(result.Resources) == 0 {
return nil, nil
}

return result.Resources[0], nil
}

// handleGetPastMeeting implements the get_past_meeting tool logic. It fetches the
// base past meeting via the meeting-service GetItxPastMeeting Goa endpoint, then
// nests the recording and transcript sub-objects (fetched from the query service,
// parent-ref scoped). Missing or inaccessible recording/transcript data yields a
// partial result plus a warning rather than a hard failure, matching get_project.
func handleGetPastMeeting(ctx context.Context, req *mcp.CallToolRequest, args GetPastMeetingArgs) (*mcp.CallToolResult, pastMeetingGetResult, error) {
logger := newToolLogger(ctx, req)

if meetingConfig == nil {
logger.ErrorContext(ctx, "meeting tools not configured")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "Error: meeting tools not configured"}},
IsError: true,
}, pastMeetingGetResult{}, nil
}

if args.UID == "" {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "Error: uid is required"}},
IsError: true,
}, pastMeetingGetResult{}, nil
}

mcpToken, err := lfxv2.ExtractMCPToken(req.Extra.TokenInfo)
if err != nil {
logger.ErrorContext(ctx, "failed to extract MCP token", "error", err)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Error: failed to extract MCP token: %v", err)}},
IsError: true,
}, pastMeetingGetResult{}, nil
}

ctx = meetingConfig.Clients.WithMCPToken(ctx, mcpToken)
clients := meetingConfig.Clients

logger.InfoContext(ctx, "fetching past meeting", "uid", args.UID)

// Base object (hard failure). Fetched via the meeting-service Goa client rather
// than the query service. BearerToken is left nil: the shared auth interceptor
// injects the exchanged LFX token on every outbound request.
version := "1"
meeting, err := clients.Meeting.GetItxPastMeeting(ctx, &meetingservice.GetItxPastMeetingPayload{
Version: &version,
PastMeetingID: args.UID,
})
Comment on lines +1347 to +1350

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not true; the LFX MCP uses token exchange to get a user-scoped LFX v2 API token. All client calls use these tokens and are subject to API-gateway authorization checks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted: Confirmed. NewClients builds one httpClient, wraps it once via wrapWithAuthInterceptor (client.go:164-177), and passes that same wrapped instance into every per-service NewClient(...) call, including the new meetinghttpclient.NewClient(..., httpClient, ...) (client.go:294). authInterceptor.RoundTrip (client.go:459-477) then injects the token from getOrExchangeToken (client.go:502-536), which does RFC 8693 token exchange for user tokens (falling back to client_credentials only for M2M/API-key). No service-specific auth branching exists — the Meeting client goes through the identical user-scoped token-exchange path as Committee/MailingList/Member/Project/QuerySvc.

if err != nil {
logger.ErrorContext(ctx, "GetItxPastMeeting failed", "error", err, "uid", args.UID)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: friendlyAPIError("failed to get past meeting", err)}},
IsError: true,
}, pastMeetingGetResult{}, nil
}

out := pastMeetingGetResult{Meeting: meeting}
var warnings []string

// The recording/transcript objects live in the query-service index, scoped to
// the past_meeting parent ref. The input uid is the meeting_and_occurrence_id.
parentRef := "past_meeting:" + args.UID

// Recording (soft failure): omit silently when absent; warn when inaccessible.
recording, err := fetchPastMeetingChildResource(ctx, clients, pastMeetingRecordingResourceType, parentRef)
if err != nil {
warnings = append(warnings, fmt.Sprintf("WARNING: past meeting recording unavailable - %s", err.Error()))
logger.ErrorContext(ctx, "getting past meeting recording failed, returning without it", "error", err, "uid", args.UID)
} else {
out.Recording = recording
}

// Transcript (soft failure): same handling as recording.
//
// GATE (LFXV2-2827): v1_past_meeting_transcript has a history of unreliable
// indexing (ARCH-393). This block is intentionally self-contained so it can be
// removed in one edit if pre-merge re-validation shows transcript indexing is
// still unreliable — in which case ship recording-only and track transcript as
// a follow-up.
transcript, err := fetchPastMeetingChildResource(ctx, clients, pastMeetingTranscriptResourceType, parentRef)
if err != nil {
warnings = append(warnings, fmt.Sprintf("WARNING: past meeting transcript unavailable - %s", err.Error()))
logger.ErrorContext(ctx, "getting past meeting transcript failed, returning without it", "error", err, "uid", args.UID)
} else {
out.Transcript = transcript
}

prettyJSON, err := json.MarshalIndent(out, "", " ")
if err != nil {
logger.ErrorContext(ctx, "failed to marshal past meeting result", "error", err)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Error: failed to format result: %v", err)}},
IsError: true,
}, pastMeetingGetResult{}, nil
}

logger.InfoContext(ctx, "get past meeting succeeded", "uid", args.UID)

content := []mcp.Content{}
for _, w := range warnings {
content = append(content, &mcp.TextContent{Text: w})
}
content = append(content, &mcp.TextContent{Text: string(prettyJSON)})

return &mcp.CallToolResult{Content: content}, out, nil
}
Loading