diff --git a/Modules/Sources/WordPressKit/Activity.swift b/Modules/Sources/WordPressKit/Activity.swift index 55851f239a5c..f509be9e8e35 100644 --- a/Modules/Sources/WordPressKit/Activity.swift +++ b/Modules/Sources/WordPressKit/Activity.swift @@ -121,6 +121,8 @@ public struct ActivityActor { public let wpcomUserID: String public let avatarURL: String public let role: String + public let isMCPAgent: Bool + public let mcpClient: String? init(dictionary: [String: Any]) { displayName = dictionary["name"] as? String ?? "" @@ -132,6 +134,8 @@ public struct ActivityActor { avatarURL = "" } role = dictionary["role"] as? String ?? "" + isMCPAgent = dictionary["is_mcp_agent"] as? Bool ?? false + mcpClient = dictionary["mcp_client"] as? String } public lazy var isJetpack: Bool = { diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index b90adf672222..1f9b5629c1c8 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -12,6 +12,7 @@ * [*] [internal] Fix retain cycle in Reader Article screen [#25364] * [*] Fix incorrect default Post Format in new posts [#25369] * [*] Posts and Pages are available to XMLRPC-disable sites [#25382] +* [*] Activity Log: Show when actions were performed by an MCP agent [#25397] 26.7 ----- diff --git a/Scripts/download-gutenberg-xcframeworks.sh b/Scripts/download-gutenberg-xcframeworks.sh index 66a7e2fc6dce..a1f2ade8c49a 100755 --- a/Scripts/download-gutenberg-xcframeworks.sh +++ b/Scripts/download-gutenberg-xcframeworks.sh @@ -52,6 +52,7 @@ if [[ -z "${FRAMEWORKS_DIR}" || "${FRAMEWORKS_DIR}" == "/" ]]; then echo "Error: invalid frameworks directory: '${FRAMEWORKS_DIR}'" >&2 exit 1 fi -cp -a "${CACHE_DIR}" "${FRAMEWORKS_DIR}" +mkdir -p "${FRAMEWORKS_DIR}" +cp -a "${CACHE_DIR}/." "${FRAMEWORKS_DIR}/" echo "Gutenberg ${VERSION} setup complete." diff --git a/Tests/KeystoneTests/Tests/Features/Activity/ActivityStringFormattingTests.swift b/Tests/KeystoneTests/Tests/Features/Activity/ActivityStringFormattingTests.swift new file mode 100644 index 000000000000..6e2076b59d62 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Activity/ActivityStringFormattingTests.swift @@ -0,0 +1,79 @@ +@testable import WordPress +@testable import WordPressKit +import Testing + +struct ActivityStringFormattingTests { + + // MARK: - actorName + + @Test func actorNameReturnsDisplayName() { + let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "administrator"]) + #expect(ActivityStringFormatting.actorName(for: actor) == "Alice") + } + + @Test func actorNameReturnsUnknownUserWhenEmpty() { + let actor = ActivityActor(dictionary: ["name": "", "type": "Person", "role": "editor"]) + #expect(ActivityStringFormatting.actorName(for: actor) == Activity.Strings.unknownUser) + } + + @Test func actorNameReturnsUnknownUserWhenMissing() { + let actor = ActivityActor(dictionary: ["type": "Person", "role": "editor"]) + #expect(ActivityStringFormatting.actorName(for: actor) == Activity.Strings.unknownUser) + } + + // MARK: - actorRole + + @Test func actorRoleReturnsCapitalizedRole() { + let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "administrator"]) + #expect(ActivityStringFormatting.actorRole(for: actor) == "Administrator") + } + + @Test func actorRoleFallsBackToTypeWhenRoleEmpty() { + let actor = ActivityActor(dictionary: ["name": "Jetpack", "type": "Application", "role": ""]) + #expect(ActivityStringFormatting.actorRole(for: actor) == "Application") + } + + @Test func actorRoleFallsBackToTypeWhenRoleMissing() { + let actor = ActivityActor(dictionary: ["name": "Jetpack", "type": "Application"]) + #expect(ActivityStringFormatting.actorRole(for: actor) == "Application") + } + + // MARK: - botName + + @Test func botNameForMCPAgent() { + let actor = ActivityActor(dictionary: [ + "name": "bot-user", + "type": "Person", + "role": "administrator", + "is_mcp_agent": true, + "mcp_client": "Claude" + ]) + #expect(ActivityStringFormatting.botName(for: actor) == "via Claude") + } + + @Test func botNameForNonMCPActor() { + let actor = ActivityActor(dictionary: ["name": "Alice", "type": "Person", "role": "editor"]) + #expect(ActivityStringFormatting.botName(for: actor) == nil) + } + + @Test func botNameForMCPAgentWithEmptyClient() { + let actor = ActivityActor(dictionary: [ + "name": "bot-user", + "type": "Person", + "role": "administrator", + "is_mcp_agent": true, + "mcp_client": "" + ]) + #expect(ActivityStringFormatting.botName(for: actor) == nil) + } + + @Test func botNameForMCPAgentWithNoClient() { + let actor = ActivityActor(dictionary: [ + "name": "bot-user", + "type": "Person", + "role": "administrator", + "is_mcp_agent": true + ]) + #expect(ActivityStringFormatting.botName(for: actor) == nil) + } +} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/ActivityTests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/ActivityTests.swift index a1b6c09eede5..c5953685975f 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/ActivityTests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/ActivityTests.swift @@ -1,20 +1,173 @@ @testable import WordPressKit -import XCTest +import Testing -class ActivityTests: XCTestCase { +struct ActivityTests { - func testActivityDecoding() throws { - let data = try XCTUnwrap(activityLogComment.data(using: .utf8)) - // This is part of the test in itself. - // If Activity is not configured as expected to decode the JSON input, JSONDecode will throw. + @Test func activityDecoding() throws { + let data = try #require(activityLogComment.data(using: .utf8)) let activity = try JSONDecoder().decode(Activity.self, from: data) - // Verify custom keys - XCTAssertEqual(activity.activityID, "AWRNRTAUjEqjFGbx8DZj") - XCTAssertFalse(activity.isRewindable) - XCTAssertEqual(activity.rewindID, "1530304735.2771") + #expect(activity.activityID == "AWRNRTAUjEqjFGbx8DZj") + #expect(activity.isRewindable == false) + #expect(activity.rewindID == "1530304735.2771") + } + + @Test func actorWithMCPAgentFields() throws { + let data = try #require(activityLogMCPAgent.data(using: .utf8)) + let activity = try JSONDecoder().decode(Activity.self, from: data) + let actor = try #require(activity.actor) + #expect(actor.isMCPAgent == true) + #expect(actor.mcpClient == "Claude") + #expect(actor.displayName == "bot-user") + #expect(actor.role == "administrator") + } + + @Test func actorWithoutMCPAgentFields() throws { + let data = try #require(activityLogComment.data(using: .utf8)) + let activity = try JSONDecoder().decode(Activity.self, from: data) + let actor = try #require(activity.actor) + #expect(actor.isMCPAgent == false) + #expect(actor.mcpClient == nil) + } + + @Test func actorWithExplicitlyFalseMCPAgent() throws { + let data = try #require(activityLogExplicitlyNotMCPAgent.data(using: .utf8)) + let activity = try JSONDecoder().decode(Activity.self, from: data) + let actor = try #require(activity.actor) + #expect(actor.isMCPAgent == false) + #expect(actor.mcpClient == nil) + } + + @Test func actorWithMCPAgentButNoClient() throws { + let data = try #require(activityLogMCPAgentNoClient.data(using: .utf8)) + let activity = try JSONDecoder().decode(Activity.self, from: data) + let actor = try #require(activity.actor) + #expect(actor.isMCPAgent == true) + #expect(actor.mcpClient == nil) } } +private let activityLogMCPAgent: String = """ +{ + "summary": "Post published", + "content": { + "text": "Post published by MCP agent" + }, + "name": "post__published", + "actor": { + "type": "Person", + "name": "bot-user", + "external_user_id": 0, + "wpcom_user_id": 100000001, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/example?s=96&d=identicon&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator", + "is_mcp_agent": true, + "mcp_client": "Claude" + }, + "type": "Create", + "published": "2026-03-17T10:00:00.000+00:00", + "generator": { + "jetpack_version": 0, + "blog_id": 137726971 + }, + "is_rewindable": false, + "rewind_id": "1710000000.0001", + "gridicon": "posts", + "status": "success", + "activity_id": "test-mcp-agent-activity", + "object": { + "type": "Post", + "object_id": 100 + }, + "is_discarded": false +} +""" + +private let activityLogExplicitlyNotMCPAgent: String = """ +{ + "summary": "Post updated", + "content": { + "text": "Post updated by a human" + }, + "name": "post__updated", + "actor": { + "type": "Person", + "name": "human-user", + "external_user_id": 0, + "wpcom_user_id": 100000002, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/example2?s=96&d=identicon&r=g", + "width": 96, + "height": 96 + }, + "role": "editor", + "is_mcp_agent": false, + "mcp_client": null + }, + "type": "Update", + "published": "2026-03-17T11:00:00.000+00:00", + "generator": { + "jetpack_version": 0, + "blog_id": 137726971 + }, + "is_rewindable": false, + "rewind_id": "1710000001.0001", + "gridicon": "posts", + "status": "success", + "activity_id": "test-not-mcp-agent", + "object": { + "type": "Post", + "object_id": 101 + }, + "is_discarded": false +} +""" + +private let activityLogMCPAgentNoClient: String = """ +{ + "summary": "Post drafted", + "content": { + "text": "Post drafted by MCP agent without client" + }, + "name": "post__drafted", + "actor": { + "type": "Person", + "name": "agent-user", + "external_user_id": 0, + "wpcom_user_id": 100000003, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/example3?s=96&d=identicon&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator", + "is_mcp_agent": true + }, + "type": "Create", + "published": "2026-03-17T12:00:00.000+00:00", + "generator": { + "jetpack_version": 0, + "blog_id": 137726971 + }, + "is_rewindable": false, + "rewind_id": "1710000002.0001", + "gridicon": "posts", + "status": "success", + "activity_id": "test-mcp-agent-no-client", + "object": { + "type": "Post", + "object_id": 102 + }, + "is_discarded": false +} +""" + // See https://github.com/wordpress-mobile/WordPress-iOS/blob/16adc688f718136ea57c45d5d26c5c13de9d2b9f/WordPress/WordPressTest/Test%20Data/activity-log-comment.json private let activityLogComment: String = """ { diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityStringFormatting.swift b/WordPress/Classes/ViewRelated/Activity/ActivityStringFormatting.swift new file mode 100644 index 000000000000..fad2dced2f21 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/ActivityStringFormatting.swift @@ -0,0 +1,26 @@ +import Foundation +import WordPressData + +struct ActivityStringFormatting { + private static let agentString = NSLocalizedString( + "activityDetail.section.agent", + value: "via %1$@", + comment: "Shows the MCP client used for an activity. %1$@ is the MCP client name (e.g. Claude)." + ) + + static func actorName(for actor: ActivityActor) -> String { + actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName + } + + static func actorRole(for actor: ActivityActor) -> String { + actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized + } + + static func botName(for actor: ActivityActor) -> String? { + guard actor.isMCPAgent, let mcpClient = actor.mcpClient, !mcpClient.isEmpty else { + return nil + } + + return String(format: Self.agentString, mcpClient) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift index fcb6a186d27d..9a4d701e510e 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView+Preview.swift @@ -62,6 +62,46 @@ extension ActivityLogDetailsView { return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) } + static var mockMCPAgentActivity: Activity { + let json = """ + { + "summary": "Post published", + "content": { + "text": "Published blog post: Getting Started with MCP" + }, + "name": "post__published", + "actor": { + "type": "Person", + "name": "bot-user", + "external_user_id": 0, + "wpcom_user_id": 100000001, + "icon": { + "type": "Image", + "url": "", + "width": 96, + "height": 96 + }, + "role": "administrator", + "is_mcp_agent": true, + "mcp_client": "Claude" + }, + "type": "Create", + "published": "2026-03-17T10:00:00.000+00:00", + "generator": { + "jetpack_version": 0, + "blog_id": 137726971 + }, + "is_rewindable": false, + "rewind_id": "1710000000.0001", + "gridicon": "posts", + "status": "success", + "activity_id": "mock-mcp-agent-activity", + "is_discarded": false + } + """ + return try! JSONDecoder().decode(Activity.self, from: json.data(using: .utf8)!) + } + static var mockLoginActivity: Activity { let json = """ { diff --git a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift index 0603ab6238b1..69358b829281 100644 --- a/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Activity/Details/ActivityLogDetailsView.swift @@ -48,12 +48,18 @@ struct ActivityLogDetailsView: View { // Actor info VStack(alignment: .leading, spacing: 2) { - Text(actor.displayName.isEmpty ? Activity.Strings.unknownUser : actor.displayName) + Text(ActivityStringFormatting.actorName(for: actor)) .font(.headline) - Text(actor.role.isEmpty ? actor.type.localizedCapitalized : actor.role.localizedCapitalized) + Text(ActivityStringFormatting.actorRole(for: actor)) .font(.subheadline) .foregroundStyle(.secondary) + + if let botName = ActivityStringFormatting.botName(for: actor) { + Text(botName) + .font(.caption) + .foregroundStyle(.secondary) + } } Spacer() @@ -188,6 +194,15 @@ private struct ActivityHeaderView: View { } } +#Preview("MCP Agent") { + NavigationView { + ActivityLogDetailsView( + activity: ActivityLogDetailsView.Mocks.mockMCPAgentActivity, + blog: Blog.mock + ) + } +} + // MARK: - Localized Strings private enum Strings { diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift index 251f4a801468..d4eb66fdb027 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowView.swift @@ -53,6 +53,16 @@ struct ActivityLogRowView: View { .font(.footnote) .foregroundColor(.secondary) } + + if let metadata = viewModel.actorMetadata { + Text("ยท") + .font(.footnote) + .foregroundColor(.secondary) + Text(metadata) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + } } } } diff --git a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift index 915e9ac8f8ca..bffa0a4dc249 100644 --- a/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/List/ActivityLogRowViewModel.swift @@ -7,7 +7,8 @@ import FormattableContentKit struct ActivityLogRowViewModel: Identifiable { let id: String - var actorSubtitle: String? + let actorSubtitle: String? + let actorMetadata: String? let title: String let subtitle: String let date: Date @@ -19,11 +20,15 @@ struct ActivityLogRowViewModel: Identifiable { init(activity: Activity) { self.activity = activity self.id = activity.activityID + if let actor = activity.actor { - if !actor.role.isEmpty { - actorSubtitle = actor.role.localizedCapitalized - } + actorSubtitle = actor.role.isEmpty ? nil : actor.role.localizedCapitalized + actorMetadata = ActivityStringFormatting.botName(for: actor) + } else { + actorSubtitle = nil + actorMetadata = nil } + self.date = activity.published self.time = activity.published.formatted(date: .omitted, time: .shortened) self.title = activity.summary.localizedCapitalized