Test an MCP Tool

Last modified by gabrielc on 2026/10/21 11:18

Steps

Write the unit tests of an MCP tool by extending the server module's own test base classes, which call the tool the way the server does and pin the text it answers with. Architecture shows where that call runs in production.

  1. Put the test in the server module and extend AbstractMCPToolTest. Carry the component annotations on your own class: the base class is deliberately unannotated, so the concrete test decides between @ComponentTest and @OldcoreTest.
    @ComponentTest
    class MyToolTest extends AbstractMCPToolTest
    {
        @InjectMockComponents
        private MyTool tool;
    
        @Override
        protected MCPTool getTool()
        {
            return this.tool;
        }
    }

    The base classes are test sources of the server module and no test JAR is published, so a tool in another module copies the two helpers from MCPToolTestUtils instead: request builds a call and textOf reads the first content block. Declare the doors the tool reads as @MockComponent fields, at minimum MCPDocumentAccess and MCPWikiReach, plus MCPServerConfiguration when the tool renders content.

  2. Call the tool through call or callText, which build the CallToolRequest from the tool's own advertised name, so a renamed tool cannot leave its test calling the old one.
    String text = callText(Map.of("reference", "Sandbox.WebHome"));
  3. Assert the exact text the tool answers with. On a refusal path the message is the one the mocked door throws, so stub the real wording the agent will see.
    @MockComponent
    private MCPDocumentAccess documentAccess;
    
    @Test
    void reportsARefusal()
    {
        when(this.documentAccess.resolveAndAuthorize("Sandbox.WebHome", Right.VIEW))
            .thenThrow(new MCPAccessDeniedException(
                "You do not have permission to view \"Sandbox.WebHome\"."));
    
        assertEquals("You do not have permission to view \"Sandbox.WebHome\".",
            callText(Map.of("reference", "Sandbox.WebHome")));
    }
  4. Extend AbstractMCPWriteToolTest instead where the tool writes. It adds the mocked access doors, the stored-document helpers, the version helpers and the failure builders that reproduce a save race, and verifyNothingSaved for the refusal paths.
    @OldcoreTest
    class MyWriteToolTest extends AbstractMCPWriteToolTest
    {
        private static final DocumentReference REFERENCE =
            new DocumentReference("xwiki", "Sandbox", "WebHome");
    
        @InjectMockComponents
        private MyWriteTool tool;
    
        @Override
        protected MCPTool getTool()
        {
            return this.tool;
        }
    
        @Test
        void refusesAStaleBaseVersion(MockitoOldcore oldcore) throws Exception
        {
            storeDocument(oldcore, REFERENCE, "content", null);
            assertTrue(callText(Map.of("reference", "Sandbox.WebHome", "base_version", "1.0"))
                .startsWith("Version conflict: "));
            verifyNothingSaved(oldcore);
        }
    }
  5. Assert the advertised schema, not only the behaviour: referenceDescription() reads the description the tool advertises for its reference parameter, which is how a schema that stopped matching the parsing is caught.
    assertEquals("The document to read, e.g. \"Sandbox.WebHome\".", referenceDescription());
  6. Run the module's tests.
    mvn -B -ntp test -pl application-ai-llm-mcp/application-ai-llm-mcp-server -Dtest=MyToolTest
  7. Run the same module under the quality profile and read the coverage gate. The gate is bound to that profile: a plain mvn test runs no gate, and the check fails the build below the module's xwiki.jacoco.instructionRatio.
    mvn -B -ntp -Pquality verify -pl application-ai-llm-mcp/application-ai-llm-mcp-server
    [INFO] All coverage checks have been met.
    [INFO] BUILD SUCCESS

FAQ

Why does the base class carry no annotations?

Because the choice between @ComponentTest and @OldcoreTest belongs to the tool: a tool that only reads components needs the first, one that touches stored documents needs the second. Annotating the base class would impose one on every tool.

Are there functional tests?

No. The module has no *IT.java tests; everything is unit tests, and the tool's answer text is what they assert.

Where do the shared helpers live?

In MCPToolTestUtils, next to the base classes: request builds a call and textOf reads the text of a result's first content block.

Related

Get Connected