Skip to content

Commit 1ba8e19

Browse files
authored
feat(calm-hub): Add Timeline Tools and Bug Fix Architecture Version Create (finos#2524)
* feat(calm-hub): add MCP timeline tools and fix architecture version creation - Add TimelineTools with 6 MCP tools: listTimelines, listTimelineVersions, getTimeline, createTimeline, createTimelineVersion, updateTimeline - Fix ArchitectureTools: split updateArchitecture into separate createArchitectureVersion (POST, ungated) and updateArchitecture (PUT, gated by allowPutOperations) to match the correct HTTP semantics - Add unit tests for TimelineTools covering all tools and edge cases, including regression guard that createTimelineVersion works when allowPutOperations=false - Extend MongoMcpIntegration and NitriteMcpIntegration with timeline end-to-end tests using isolated namespaces (mcp-integration/mcp-nitrite) - Fix MongoTimelineIntegration and NitriteTimelineIntegration to capture the created timeline ID dynamically from the Location header instead of hardcoding ID 1, resolving a pre-existing test ordering fragility Fixes finos#2522 * fix(calm-hub): address PR review feedback on MCP tools - Fix createArchitectureVersion to resolve and preserve existing architecture name/description before calling the store, preventing null overwrites (Copilot high severity) - Add regression test asserting name/description are preserved when publishing a new architecture version - Remove dead architectureStore.createArchitectureForVersion(any()) call in create_architecture_version_successfully test (Copilot low severity) - Replace brittle substring Location-header parsing in MongoTimelineIntegration and NitriteTimelineIntegration with Pattern.compile("/timelines/(\d+)") regex, matching the ID_PATTERN idiom used in MCP integration tests - Align PatternTools.updatePattern @tool description to advertise the PUT-operations gate, consistent with updateArchitecture and updateTimeline - Add three missing updateTimeline validation unit tests: invalid namespace, blank JSON, and non-positive timeline ID
1 parent e0c92db commit 1ba8e19

9 files changed

Lines changed: 1186 additions & 21 deletions

File tree

calm-hub/src/integration-test/java/integration/MongoMcpIntegration.java

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.finos.calm.mcp.tools.PatternTools;
2020
import org.finos.calm.mcp.tools.SearchTools;
2121
import org.finos.calm.mcp.tools.StandardTools;
22+
import org.finos.calm.mcp.tools.TimelineTools;
2223
import org.junit.jupiter.api.BeforeEach;
2324
import org.junit.jupiter.api.MethodOrderer;
2425
import org.junit.jupiter.api.Order;
@@ -104,6 +105,7 @@ public class MongoMcpIntegration {
104105
private static int createdPatternId;
105106
private static int createdStandardId;
106107
private static int createdAdrId;
108+
private static int createdTimelineId;
107109

108110
@Inject
109111
ArchitectureTools architectureTools;
@@ -132,6 +134,9 @@ public class MongoMcpIntegration {
132134
@Inject
133135
AdrTools adrTools;
134136

137+
@Inject
138+
TimelineTools timelineTools;
139+
135140
private static String text(ToolResponse r) {
136141
return ((TextContent) r.firstContent()).text();
137142
}
@@ -170,6 +175,19 @@ public void setup() {
170175
);
171176
}
172177

178+
if (!database.listCollectionNames().into(new ArrayList<>()).contains("timelines")) {
179+
database.createCollection("timelines");
180+
database.getCollection("timelines").insertOne(
181+
new Document("namespace", "finos").append("timelines", new ArrayList<>())
182+
);
183+
}
184+
185+
if (database.getCollection("timelines").countDocuments(new Document("namespace", "mcp-integration")) == 0) {
186+
database.getCollection("timelines").insertOne(
187+
new Document("namespace", "mcp-integration").append("timelines", new ArrayList<>())
188+
);
189+
}
190+
173191
MongoSetup.counterSetup(database);
174192
MongoSetup.namespaceSetup(database);
175193
MongoSetup.domainSetup(database);
@@ -400,7 +418,7 @@ void mcp_validation_rejects_decorator_type_filter_with_newline() {
400418

401419
@Test
402420
@Order(26)
403-
void mcp_update_architecture_publishes_new_version() {
421+
void mcp_update_architecture_upserts_version_via_put() {
404422
ToolResponse result = architectureTools.updateArchitecture(
405423
"finos", createdArchitectureId, "1.1.0", "{\"name\": \"mcp-test-architecture-updated\"}", null, null);
406424
assertThat(result.isError(), is(false));
@@ -753,4 +771,123 @@ void mcp_list_adrs_for_nonexistent_namespace_returns_error() {
753771
assertThat(result.isError(), is(true));
754772
assertThat(text(result), containsString("not found"));
755773
}
774+
775+
// --- createArchitectureVersion (POST — no PUT gate) ---
776+
777+
@Test
778+
@Order(66)
779+
void mcp_create_architecture_version_with_post() {
780+
ToolResponse result = architectureTools.createArchitectureVersion(
781+
"finos", createdArchitectureId, "1.2.0", "{\"name\": \"mcp-test-architecture-v2\"}");
782+
assertThat(result.isError(), is(false));
783+
assertThat(text(result), containsString("created successfully"));
784+
assertThat(text(result), containsString("1.2.0"));
785+
}
786+
787+
@Test
788+
@Order(67)
789+
void mcp_create_architecture_version_returns_error_for_duplicate() {
790+
ToolResponse result = architectureTools.createArchitectureVersion(
791+
"finos", createdArchitectureId, "1.2.0", "{\"name\": \"duplicate\"}");
792+
assertThat(result.isError(), is(true));
793+
assertThat(text(result), containsString("already exists"));
794+
}
795+
796+
@Test
797+
@Order(68)
798+
void mcp_list_architecture_versions_includes_post_created_version() {
799+
ToolResponse result = architectureTools.listArchitectureVersions("finos", createdArchitectureId);
800+
assertThat(result.isError(), is(false));
801+
assertThat(text(result), containsString("1.0.0"));
802+
assertThat(text(result), containsString("1.2.0"));
803+
}
804+
805+
// --- Timeline Tools ---
806+
807+
@Test
808+
@Order(69)
809+
void mcp_create_timeline() {
810+
ToolResponse result = timelineTools.createTimeline("mcp-integration", "MCP Test Timeline", "Integration test timeline", "{\"events\":[]}");
811+
assertThat(result.isError(), is(false));
812+
assertThat(text(result), containsString("created successfully"));
813+
814+
Matcher matcher = ID_PATTERN.matcher(text(result));
815+
assertThat("Response should contain timeline ID", matcher.find());
816+
createdTimelineId = Integer.parseInt(matcher.group(1));
817+
logger.info("Created timeline with ID: {}", createdTimelineId);
818+
}
819+
820+
@Test
821+
@Order(70)
822+
void mcp_list_timelines_contains_created() {
823+
ToolResponse result = timelineTools.listTimelines("mcp-integration");
824+
assertThat(result.isError(), is(false));
825+
assertThat(text(result), containsString("MCP Test Timeline"));
826+
}
827+
828+
@Test
829+
@Order(71)
830+
void mcp_list_timeline_versions() {
831+
ToolResponse result = timelineTools.listTimelineVersions("mcp-integration", createdTimelineId);
832+
assertThat(result.isError(), is(false));
833+
assertThat(text(result), containsString("1.0.0"));
834+
}
835+
836+
@Test
837+
@Order(72)
838+
void mcp_get_timeline() {
839+
ToolResponse result = timelineTools.getTimeline("mcp-integration", createdTimelineId, "1.0.0");
840+
assertThat(result.isError(), is(false));
841+
assertThat(text(result), containsString("events"));
842+
}
843+
844+
@Test
845+
@Order(73)
846+
void mcp_create_timeline_version() {
847+
ToolResponse result = timelineTools.createTimelineVersion("mcp-integration", createdTimelineId, "1.1.0", "{\"events\":[{\"id\":\"e1\"}]}");
848+
assertThat(result.isError(), is(false));
849+
assertThat(text(result), containsString("created successfully"));
850+
assertThat(text(result), containsString("1.1.0"));
851+
}
852+
853+
@Test
854+
@Order(74)
855+
void mcp_list_timeline_versions_includes_new_version() {
856+
ToolResponse result = timelineTools.listTimelineVersions("mcp-integration", createdTimelineId);
857+
assertThat(result.isError(), is(false));
858+
assertThat(text(result), containsString("1.0.0"));
859+
assertThat(text(result), containsString("1.1.0"));
860+
}
861+
862+
@Test
863+
@Order(75)
864+
void mcp_create_timeline_version_returns_error_for_duplicate_version() {
865+
ToolResponse result = timelineTools.createTimelineVersion("mcp-integration", createdTimelineId, "1.1.0", "{\"events\":[]}");
866+
assertThat(result.isError(), is(true));
867+
assertThat(text(result), containsString("already exists"));
868+
}
869+
870+
@Test
871+
@Order(76)
872+
void mcp_update_timeline() {
873+
ToolResponse result = timelineTools.updateTimeline("mcp-integration", createdTimelineId, "1.1.0", "{\"events\":[{\"id\":\"e1-updated\"}]}");
874+
assertThat(result.isError(), is(false));
875+
assertThat(text(result), containsString("updated successfully"));
876+
}
877+
878+
@Test
879+
@Order(77)
880+
void mcp_get_timeline_after_update() {
881+
ToolResponse result = timelineTools.getTimeline("mcp-integration", createdTimelineId, "1.1.0");
882+
assertThat(result.isError(), is(false));
883+
assertThat(text(result), containsString("e1-updated"));
884+
}
885+
886+
@Test
887+
@Order(78)
888+
void mcp_list_timelines_returns_error_for_nonexistent_namespace() {
889+
ToolResponse result = timelineTools.listTimelines("nonexistent");
890+
assertThat(result.isError(), is(true));
891+
assertThat(text(result), containsString("not found"));
892+
}
756893
}

calm-hub/src/integration-test/java/integration/MongoTimelineIntegration.java

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import org.slf4j.LoggerFactory;
1313

1414
import java.util.ArrayList;
15+
import java.util.regex.Matcher;
16+
import java.util.regex.Pattern;
1517

1618
import static io.restassured.RestAssured.given;
1719
import static integration.MongoSetup.counterSetup;
@@ -27,6 +29,10 @@ public class MongoTimelineIntegration {
2729
public static final String TIMELINE = "{\"moments\": []}";
2830
public static final String TIMELINE_V2 = "{\"moments\": [{\"unique-id\": \"1.0.0\"}]}";
2931

32+
private static final Pattern TIMELINE_ID_PATTERN = Pattern.compile("/timelines/(\\d+)");
33+
34+
private static int createdTimelineId;
35+
3036
@BeforeEach
3137
public void setupTimelines() {
3238
String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class);
@@ -74,20 +80,28 @@ void end_to_end_create_a_timeline() {
7480
}
7581
""";
7682

77-
given()
83+
String location = given()
7884
.body(payload)
7985
.header("Content-Type", "application/json")
8086
.when().post("/calm/namespaces/finos/timelines")
8187
.then()
8288
.statusCode(201)
83-
.header("Location", containsString("calm/namespaces/finos/timelines/1"));
89+
.header("Location", containsString("calm/namespaces/finos/timelines/"))
90+
.extract().header("Location");
91+
92+
Matcher matcher = TIMELINE_ID_PATTERN.matcher(location);
93+
if (!matcher.find()) {
94+
throw new IllegalStateException("Could not extract timeline ID from Location header: " + location);
95+
}
96+
createdTimelineId = Integer.parseInt(matcher.group(1));
97+
logger.info("Created timeline with ID: {}", createdTimelineId);
8498
}
8599

86100
@Test
87101
@Order(3)
88102
void end_to_end_verify_versions() {
89103
given()
90-
.when().get("/calm/namespaces/finos/timelines/1/versions")
104+
.when().get("/calm/namespaces/finos/timelines/" + createdTimelineId + "/versions")
91105
.then()
92106
.statusCode(200)
93107
.body("values", hasSize(1))
@@ -98,7 +112,7 @@ void end_to_end_verify_versions() {
98112
@Order(4)
99113
void end_to_end_verify_timeline() {
100114
given()
101-
.when().get("/calm/namespaces/finos/timelines/1/versions/1.0.0")
115+
.when().get("/calm/namespaces/finos/timelines/" + createdTimelineId + "/versions/1.0.0")
102116
.then()
103117
.statusCode(200)
104118
.body(equalTo(TIMELINE));
@@ -112,13 +126,13 @@ void end_to_end_create_new_version() {
112126
given()
113127
.body(envelope)
114128
.header("Content-Type", "application/json")
115-
.when().post("/calm/namespaces/finos/timelines/1/versions/2.0.0")
129+
.when().post("/calm/namespaces/finos/timelines/" + createdTimelineId + "/versions/2.0.0")
116130
.then()
117131
.statusCode(201)
118-
.header("Location", containsString("calm/namespaces/finos/timelines/1/versions/2.0.0"));
132+
.header("Location", containsString("calm/namespaces/finos/timelines/" + createdTimelineId + "/versions/2.0.0"));
119133

120134
given()
121-
.when().get("/calm/namespaces/finos/timelines/1/versions/2.0.0")
135+
.when().get("/calm/namespaces/finos/timelines/" + createdTimelineId + "/versions/2.0.0")
122136
.then()
123137
.statusCode(200)
124138
.body(equalTo(TIMELINE_V2));

0 commit comments

Comments
 (0)