Skip to content

MCP FAT: Add negative test scenarios - #35733

Open
TheoGkoumas wants to merge 9 commits into
OpenLiberty:integrationfrom
TheoGkoumas:mcp-neg-tests
Open

TheoGkoumas wants to merge 9 commits into
OpenLiberty:integrationfrom
TheoGkoumas:mcp-neg-tests

Conversation

@TheoGkoumas

Copy link
Copy Markdown
Member

Fixes #35543

@TheoGkoumas
TheoGkoumas marked this pull request as ready for review September 10, 2026 13:48

@IssacAX123 IssacAX123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@TheoGkoumas

TheoGkoumas commented Sep 11, 2026 •

Copy link
Copy Markdown
Member Author

#build (view Open Liberty Personal Build - ✅ completed successfully!)

Note: Target locations of links might be accessible only to IBM employees.

@LibbyBot

Copy link
Copy Markdown
Member

Code analysis and actions

DO NOT DELETE THIS COMMENT.
  • 12 FAT files were changed, added, or removed.

  • Check that the build did not break the affected FAT suite(s).

  • Please describe in a separate comment how you tested your changes.

@Azquelt Azquelt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are a lot of problems here and a lot of the tests appear to make sense in isolation, but would be better added to existing tests. In addition, a lot of the new tests ignore the patterns in the existing tests.

Some of this reads like AI code that hasn't been properly read and understood.

Comment on lines +297 to +299
// Wait for at least one @PreDestroy to confirm both calls ran
assertNotNull("@PreDestroy must fire after async tool calls",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't you need to wait for two?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, assertions have been fixed.

Comment on lines +301 to +309
List<String> postConstructEvents = server.findStringsInLogsUsingMark(
".*\\[LIFECYCLE] @PostConstruct AsyncLifecycleTools.*", server.getDefaultLogFile());
List<String> preDestroyEvents = server.findStringsInLogsUsingMark(
".*\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools.*", server.getDefaultLogFile());

assertTrue("Two separate @PostConstruct events must fire (Dependent scope, no instance reuse)",
postConstructEvents.size() >= 2);
assertTrue("Two separate @PreDestroy events must fire",
preDestroyEvents.size() >= 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These checks are very loose. Please do a similar check to the previous test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed.

Comment on lines +168 to +170
// Wait for two PreDestroy events to confirm two separate bean instances were created
assertNotNull("First @PreDestroy should fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you not need to wait for two messages?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, assertions have been fixed.

Comment on lines +172 to +180
List<String> postConstructEvents = server.findStringsInLogsUsingMark(
".*\\[LIFECYCLE] @PostConstruct ClassTool.*", server.getDefaultLogFile());
List<String> preDestroyEvents = server.findStringsInLogsUsingMark(
".*\\[LIFECYCLE] @PreDestroy ClassTool.*", server.getDefaultLogFile());

assertTrue("Two @PostConstruct events must fire for two calls (Dependent scope creates a new instance per call)",
postConstructEvents.size() >= 2);
assertTrue("Two @PreDestroy events must fire for two calls",
preDestroyEvents.size() >= 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again, loose checks, see previous test

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed.

Comment on lines +214 to +235
// Regardless of success/error the @PreDestroy callback must fire
assertNotNull("@PreDestroy must fire even when the tool call completes (success or error)",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool"));

List<String> lifecycleMessages = server.findStringsInLogsUsingMark(
".*\\[LIFECYCLE].*", server.getDefaultLogFile());
assertFalse("Lifecycle log must not be empty", lifecycleMessages.isEmpty());

// @PostConstruct must precede @PreDestroy in the log
int postConstructIdx = -1;
int preDestroyIdx = -1;
for (int i = 0; i < lifecycleMessages.size(); i++) {
if (lifecycleMessages.get(i).contains("@PostConstruct ClassTool") && postConstructIdx == -1) {
postConstructIdx = i;
}
if (lifecycleMessages.get(i).contains("@PreDestroy ClassTool") && preDestroyIdx == -1) {
preDestroyIdx = i;
}
}
assertTrue("@PostConstruct must appear before @PreDestroy in the lifecycle log",
postConstructIdx >= 0 && preDestroyIdx > postConstructIdx);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Loose checks, see previous comments.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test strategy has changed in all corresponding tests with similar loose checks.
Additionally - on a general note - javadoc also calibrated to reflect the actual scenario.
Fixed.

Comment on lines +829 to +834
/**
* Negative test: supplying an unknown {@code action} value to {@code async-test-tool}
* must return an internal-error response; the same invariant applies to the async path.
*/
@Test
public void testCallAsyncToolWithUnknownActionReturnsInternalError() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is testing the behaviour of a test fixture. Not necessary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

True. Test will be removed.

Comment on lines +871 to +879
/**
* Negative test: {@link io.openliberty.mcp.tools.ToolManager#getTool} must return
* {@code null} when the requested tool name has never been registered.
* Verified in-process via {@link ToolManagerTestServlet}.
*/
@Test
public void testGetToolReturnsNullForNonExistentTool() throws Exception {
runTest(server, APP_NAME + "/toolManagerTestServlet", "testGetToolReturnsNullForNonExistentTool");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tests added to ToolManagerTestServlet are run automatically via the TestServlet annotation, so you don't need this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed.

Comment on lines +79 to +85
/**
* Negative test: a {@code tools/list} call must report that both {@code exception}
* and {@code failureMechanism} are required arguments in {@code asyncErrorTool}'s
* {@code inputSchema}; neither may be absent from the {@code required} array.
*/
@Test
public void testAsyncErrorToolSchemaListsBothArgumentsAsRequired() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is written as a test for a text fixture.

If we did want to test this, I think we would do it as part of testToolList.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

True, test method should be removed.

Comment on lines +130 to +138
/**
* Negative test: supplying an unknown {@code exception} value (not one of the
* recognised class names) must return a well-formed {@code isError:true} JSON-RPC
* result; the server must not crash or produce an HTTP 500.
* The tool's default branch throws a {@link io.openliberty.mcp.tools.ToolCallException},
* so the response content must be the user-visible exception message.
*/
@Test
public void testUnknownExceptionTypeReturnsUserErrorNotServerCrash() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is testing a test fixture.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

True, test method should be removed.

Comment on lines +156 to +165
/**
* Negative test: when the async tool fails via {@code FAILED} (returns a
* {@code CompletableFuture.failedStage}), the CDI container must not keep any
* stale bean state; a second call with a different failure mechanism must still
* succeed independently. This is verified by calling twice in succession and
* asserting both produce well-formed {@code isError:true} responses (no session
* contamination or cached bean failure).
*/
@Test
public void testSuccessiveFailedStageCallsRemainIsolated() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is also testing a test fixture.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

True, test method should be removed.

@TheoGkoumas
TheoGkoumas requested a review from Azquelt September 15, 2026 16:04
@TheoGkoumas

Copy link
Copy Markdown
Member Author

Test methods have been revisited all over again and refactored accordingly based on the accurate PR comments.
Bod went overjoyed in this case and should have been treated more carefully.

Comment on lines +287 to +291
// Wait for both @PreDestroy events — each call must destroy its own instance
assertNotNull("First @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"));
assertNotNull("Second @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"));

@Azquelt Azquelt Sep 15, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unfortunately, this doesn't quite work, the second wait will just find the same message as the first wait.

We probably need something like this (possibly factored out into its own utility method):

Suggested change
// Wait for both @PreDestroy events — each call must destroy its own instance
assertNotNull("First @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"));
assertNotNull("Second @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"));
// Wait for both @PreDestroy events — each call must destroy its own instance
long startTime = System.nanoTime();
boolean found = false;
while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) {
List<String> predestroyMessages = server.findStringInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools");
if (predestroyMessages.size() >= 2) {
found = true;
break;
}
}
if (!found) {
fail("Did not found two PreDestroy messages");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Or we could artificially cause something else to be logged after that and wait on that message.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Suggestion has been applied.

Comment on lines +166 to +170
// Wait for both @PreDestroy events - each call must destroy its own instance
assertNotNull("First @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool"));
assertNotNull("Second @PreDestroy must fire",
server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This has the same problem as above, the second wait will just find the first message.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The previous suggestion has been applied here as well.

Comment on lines +144 to +150
/**
* Verifies that an {@code initialize} request whose body omits {@code protocolVersion}
* is tolerated: the server falls back to its preferred version ({@code 2025-11-25}) and
* returns a normal, successful initialize result rather than an error.
*/
@Test
public void testInitializeWithMissingProtocolVersionFallsBackToServerDefault() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I can't find any version of the spec that permits the client to omit the protocol version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The MCP spec says protocolVersion is a required string in the initialize params.
This test validates non-spec-compliant behavior (silent fallback to server default when a required field is missing). This test should be removed because the spec does not permit omitting protocolVersion; it's required.
The "fallback to server default" behavior is also not defined by the spec for missing required fields.

Comment on lines +368 to +369
String errorType = operationStats.getErrorType();
assertNotNull("ErrorType must not be null for a business-error tool call", errorType);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We're still not asserting the actual error type here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Error type is now asserted.

Comment on lines +252 to +289
// Negative Tests

// --- Monitoring and Management ---

/**
* Verifies that an operation MBean for a tool that threw a {@code ToolCallException} exposes
* {@code RpcResponseStatusCode} as {@code "error"} and {@code ErrorType} as {@code "tool_error"}.
*/
@Test
public void testErrorToolMBeanHasErrorStatusAndNonNullErrorType() throws Exception {
client.callMCP(BUSINESS_ERROR_REQUEST);
runTest(server, SERVLET, "testErrorToolMBeanHasErrorStatusAndNonNullErrorType");
}

// --- Bean Lifecycle ---

/**
* Verifies that the session MBean is registered with a positive count and duration
* after the session is explicitly ended via DELETE.
*/
@Test
public void testSessionMBeanPresentAfterSessionDeleted() throws Exception {
client.callMCP(PING_REQUEST);
client.deleteSession();
runTest(server, SERVLET, "testSessionMBeanPresentAfterSessionDeleted");
}

// --- Tool Metadata and Schema Generation ---

/**
* Verifies that every registered operation MBean exposes a non-null, non-blank
* {@code McpMethodName} attribute.
*/
@Test
public void testAllOperationMBeansHaveNonEmptyMethodName() throws Exception {
client.callMCP(ADD_REQUEST);
runTest(server, SERVLET, "testAllOperationMBeansHaveNonEmptyMethodName");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As noted previously, doing these tests from outside the server in McpMonitorTest is sufficient.

@TheoGkoumas TheoGkoumas Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That is true. I confirm that the three negative test scenarios are fully and equivalently covered from outside the server in McpMonitorTest. Tests will be removed as redundant.

@Azquelt
Azquelt self-requested a review September 18, 2026 13:21

@Azquelt Azquelt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is looking way better. There are just a handful of comments left above to fix.

@Azquelt
Azquelt dismissed their stale review September 18, 2026 13:22

I may not be available to re-review. Most of my requests have been addressed.

@TheoGkoumas
TheoGkoumas requested a review from Azquelt September 28, 2026 13:08
Comment on lines +500 to +504
// Both must succeed with isError:false
assertFalse("Alpha response must not indicate an error",
new JSONObject(alphaResponse).getJSONObject("result").getBoolean("isError"));
assertFalse("Beta response must not indicate an error",
new JSONObject(betaResponse).getJSONObject("result").getBoolean("isError"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm a little concerned that a JSON-RPC error response might pass this check?

At best you'd get a null pointer exception if getJSONObject("result") returns null if there's no result object.

Comment on lines +292 to +299
while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) {
List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile());

if (predestroyMessages.size() >= 2) {
found = true;
break;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add a short sleep in here rather than doing a tight loop.

Suggested change
while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) {
List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile());
if (predestroyMessages.size() >= 2) {
found = true;
break;
}
}
while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) {
List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile());
if (predestroyMessages.size() >= 2) {
found = true;
break;
}
Thread.sleep(100);
}

Comment on lines +171 to +178
while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) {
List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool", server.getDefaultLogFile());

if (predestroyMessages.size() >= 2) {
found = true;
break;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add a short sleep here too.

@Azquelt

Azquelt commented Sep 29, 2026

Copy link
Copy Markdown
Member

Looking much better, just a few comments noted above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Write negative testing scenarios

4 participants