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
16 changes: 16 additions & 0 deletions services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@
<artifactId>app-center-services</artifactId>
<scope>provided</scope>
</dependency>
<!-- MCP Server API (used by the MCP tools exposed to the AI agent) -->
<dependency>
<groupId>io.meeds.mcp-server</groupId>
<artifactId>mcp-server-tools</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<resources>
Expand Down Expand Up @@ -141,6 +147,16 @@
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<!-- Required so MCP tool method parameter names are retained and bound by name -->
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>io.openapitools.swagger</groupId>
<artifactId>swagger-maven-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,21 @@ public boolean isConfiguredForIdentity(String identityId) {
public void setConfiguredForIdentity(String identityId, boolean configured) {
}

/**
* Reads the call id out of a meeting link this provider generated, the
* server-side counterpart of the {@code findCallId(url)} of the provider's
* JavaScript module. The recordings of a meeting are found through it, from
* the conference link an agenda event stores.
* <p>
* Optional: a provider that does not implement it keeps this default, and its
* meetings read as held on another provider.
*
* @param url a meeting link, possibly carrying a query string
* @return the call id the link points to, or <code>null</code> when the link
* is not one of this provider's meetings
*/
public String findCallId(String url) {
return null;
}

}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.mcp;

import java.util.Date;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import org.exoplatform.commons.exception.ObjectNotFoundException;
import org.exoplatform.webconferencing.WebConferencingService;
import org.exoplatform.webconferencing.mcp.model.MeetingRecordingModel;
import org.exoplatform.webconferencing.mcp.model.MeetingRecordingsModel;
import org.exoplatform.webconferencing.model.RecordingInfo;
import org.exoplatform.webconferencing.util.RecordingDateUtils;

import io.meeds.mcp.server.plugin.McpToolPlugin;

/**
* The assistant tools of the web conferencing add-on: one public method per
* tool, named in snake case, each described in this add-on's
* {@code ai-tool-definitions.json}.
* <p>
* The acting user is the authenticated request's, never an argument: the
* recordings come back with that user's own rights.
*/
@Service
@Profile("mcp-server")
public class MeetingRecordingMcpTool implements McpToolPlugin {

/** The reason of an empty list: the meeting was not recorded, or not by this platform. */
public static final String NO_RECORDING = "NO_RECORDING";

/** The reason of an empty list: the link is not one of an active provider's meetings. */
public static final String OTHER_PROVIDER = "OTHER_PROVIDER";

/**
* The answer to a date argument that cannot be read: it names the argument,
* the value, the accepted formats with an example, and asks the model to
* resolve a relative expression itself.
*/
public static final String INVALID_DATE =
"Invalid '%s' value '%s'. Provide an RFC3339 date-time (e.g. 2026-10-23T09:00:00Z) "
+ "or a date yyyy-MM-dd (e.g. 2026-10-23), in UTC. Resolve a relative "
+ "expression such as 'yesterday' or 'last week' into such a date yourself "
+ "before calling the tool again.";

private final WebConferencingService webConferencingService;

/**
* Builds the tool.
*
* @param webConferencingService the service holding the recordings lookup
*/
@Autowired
public MeetingRecordingMcpTool(WebConferencingService webConferencingService) {
this.webConferencingService = webConferencingService;
}

/**
* Lists the recordings of the meeting behind a conference link that the
* current user can open, newest first ({@code get_meeting_recordings}).
*
* @param conferenceUrl the meeting link, as {@code get_event_conference}
* returns it
* @param recordedFrom the earliest recording date, RFC3339 or
* {@code yyyy-MM-dd}, optional
* @param recordedTo the end of the range, RFC3339 or {@code yyyy-MM-dd},
* optional: the server adds its grace, since a recording is saved
* after its meeting ends
* @return the recordings, or an empty list with its reason
* @throws IllegalArgumentException when the link is missing, or a date is not
* in an accepted format: its message, {@link #INVALID_DATE}, tells
* the model how to call again
* @throws IllegalAccessException when the user may not read the meeting's
* recordings: not a member of its space, or not a participant
*/
public MeetingRecordingsModel getMeetingRecordings(String conferenceUrl,
String recordedFrom,
String recordedTo) throws IllegalAccessException {
if (StringUtils.isBlank(conferenceUrl)) {
throw new IllegalArgumentException("conference_url is mandatory: pass the url get_event_conference returns");
}
Date from = parseDateArgument("recorded_from", recordedFrom, false);
Date to = parseDateArgument("recorded_to", recordedTo, true);
try {
List<MeetingRecordingModel> recordings =
webConferencingService.getCallRecordingsByConference(conferenceUrl,
getCurrentUserName(),
from,
to)
.stream()
.map(MeetingRecordingMcpTool::toModel)
.toList();
return new MeetingRecordingsModel(recordings, recordings.isEmpty() ? NO_RECORDING : null);
} catch (ObjectNotFoundException e) {
String reason = WebConferencingService.RECORDINGS_OTHER_PROVIDER.equals(e.getMessage()) ? OTHER_PROVIDER : NO_RECORDING;
return new MeetingRecordingsModel(List.of(), reason);
}
}

/**
* Reads a date argument of the tool. An unreadable value is refused with a
* message the model can act on, the shape the platform's own tools use for
* bad input, rather than the REST layer's message code.
*
* @param name the argument's name, as the model sends it
* @param value the argument's value, or a blank value for none
* @param endOfRange true when the date closes the range
* @return the date, or <code>null</code> when the value is blank
* @throws IllegalArgumentException with {@link #INVALID_DATE} when the value
* is not in an accepted format
*/
private static Date parseDateArgument(String name, String value, boolean endOfRange) {
try {
return RecordingDateUtils.parseDate(value, endOfRange);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(INVALID_DATE.formatted(name, value), e);
}
}

/**
* Maps a recording to the tool's answer.
*
* @param recording the recording
* @return its model
*/
private static MeetingRecordingModel toModel(RecordingInfo recording) {
return new MeetingRecordingModel(recording.getDocumentId(),
recording.getTitle(),
RecordingDateUtils.formatDate(recording.getRecordedDate()),
RecordingDateUtils.formatDate(recording.getStartedDate()),
recording.getSizeBytes(),
recording.getMimeType(),
recording.getPreviewUrl());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.mcp.model;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

/**
* A recording of a meeting, as the assistant tool answers it.
*
* @param documentId the id of the recording's document, the one
* {@code get_document_transcription_by_id} takes
* @param title the title displayed in Documents: the meeting's title and date
* @param recordedDate when the recording was saved, an ISO 8601 instant in UTC
* @param startedDate when the recording started, an ISO 8601 instant in UTC,
* absent when its file name does not tell it
* @param sizeBytes the size of the recording in bytes, -1 when unknown
* @param mimeType the media type of the recording
* @param previewUrl the link opening the recording in Documents
*/
@JsonInclude(value = Include.NON_EMPTY)
public record MeetingRecordingModel(String documentId,
String title,
String recordedDate,
String startedDate,
long sizeBytes,
String mimeType,
String previewUrl) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.mcp.model;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

/**
* The recordings of a meeting, newest first, or the reason there are none.
*
* @param recordings the recordings the user can open, newest first
* @param reason when the list is empty: {@code NO_RECORDING} or
* {@code OTHER_PROVIDER}; absent otherwise
*/
@JsonInclude(value = Include.NON_NULL)
public record MeetingRecordingsModel(List<MeetingRecordingModel> recordings, String reason) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (C) 2026 eXo Platform SAS.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <gnu.org/licenses>.
*/
package org.exoplatform.webconferencing.model;

/**
* The call a meeting link points to, as one of the active providers reads it.
*
* @param providerType the type of the provider that recognised the link
* @param callId the id of the call the link points to
*/
public record ConferenceCall(String providerType, String callId) {
}
Loading
Loading