From e4579eab999f2c3094cd00887a8a18f75f0578af Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Mon, 13 Jul 2026 20:17:38 +0200 Subject: [PATCH 1/3] chore(spec-012): mark content-mcp-tools in progress, link issue #23 Refs #23 --- docs/specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index cafadfd..66b86f4 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -17,7 +17,7 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done | | 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | #19 | done | | 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | #21 | done | -| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | — | open | +| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | in progress | | 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | — | open | | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | — | open | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | — | open | From ef29459917a1bae8ddc0854230d0febbcdcf7086 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Mon, 13 Jul 2026 20:52:31 +0200 Subject: [PATCH 2/3] feat(spec-012): content MCP tools (the four tools on /mcp) - ContentMcpToolProvider implements McpToolProvider (auto-aggregated by McpServerConfig, gated on openelements.mcp.enabled): search_content, list_posts, get_post, list_categories. Thin adapters over ContentSearchService using McpTools/McpToolSupport/McpPaging; paging clamped; bootstrap guard -> McpUnavailableException; get_post maps IllegalArgumentException (neither url/id) and NoSuchElementException (absent). Tool logic extracted to package-private methods for testing. - ContentMcpToolProviderTest via a capturing ContentSearchService subclass. - Drift: spec 001's ContentMcpApplicationTests asserted an empty tool catalog; updated to expect the provider, with a Drift Log entry in 001-project-skeleton/behaviors.md. - Build: pin spring-services to 1.3.0-20260712.175350-13 (the floating 1.3.0-SNAPSHOT published a broken dependency-less -14 POM). 130 tests green. - steps.md; refresh CLAUDE.md. Refs #23 --- CLAUDE.md | 12 +- docs/specs/001-project-skeleton/behaviors.md | 11 + docs/specs/012-content-mcp-tools/steps.md | 51 +++++ pom.xml | 5 +- .../content/ContentMcpToolProvider.java | 143 ++++++++++++ .../content/ContentMcpApplicationTests.java | 10 +- .../content/ContentMcpToolProviderTest.java | 204 ++++++++++++++++++ 7 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 docs/specs/012-content-mcp-tools/steps.md create mode 100644 src/main/java/com/openelements/content/ContentMcpToolProvider.java create mode 100644 src/test/java/com/openelements/content/ContentMcpToolProviderTest.java diff --git a/CLAUDE.md b/CLAUDE.md index d90d62e..2b774a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools │ ├── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient │ ├── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex │ ├── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded) -│ └── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records) +│ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records) +│ └── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp ├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config ├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup) └── docs/ @@ -119,6 +120,15 @@ bootstrapping and to never overlap, with per-source fault isolation. On the read `multi-search` bodies (AND-combined `source`/`locale`/`categories`/`since` filters, `publishedDate:desc` tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML-safe snippets, plus `listPosts`, `getByUrlOrId`, and `categoryFacets`. Read-only; the scoped key comes in spec 013. +`ContentMcpToolProvider` implements the library `McpToolProvider` (auto-aggregated by `McpServerConfig`) +and exposes the four tools on `/mcp` — `search_content`, `list_posts`, `get_post`, `list_categories` — +as thin adapters over `ContentSearchService`, using the house helpers for schemas/paging/error mapping +(invalid-argument / not-found / temporary-unavailable during bootstrap). + +> **Build note:** `spring-services` is pinned to a specific snapshot timestamp +> (`1.3.0-20260712.175350-13`) in `pom.xml`, not the floating `1.3.0-SNAPSHOT`, because a later +> snapshot was published with a broken (dependency-less) POM. Keep it pinned until a released `1.3.0` +> exists. > **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA > datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See diff --git a/docs/specs/001-project-skeleton/behaviors.md b/docs/specs/001-project-skeleton/behaviors.md index de80ee4..b875464 100644 --- a/docs/specs/001-project-skeleton/behaviors.md +++ b/docs/specs/001-project-skeleton/behaviors.md @@ -47,3 +47,14 @@ - **Given** `openelements.mcp.enabled=false` - **When** the app starts - **Then** no `/mcp` endpoint is exposed (the MCP server beans are not created) + +--- + +## Drift Log + +### 2026-07-13 — Caused by spec `012-content-mcp-tools` + +- **Affected scenario:** /mcp endpoint is exposed +- **Original behavior:** the `/mcp` server advertised the configured `server-name`/`server-version` with an **empty content-tool catalog** (no `McpToolProvider` beans registered). +- **Current behavior:** spec 012 registers `ContentMcpToolProvider`, so the context now contains one `McpToolProvider` bean and `/mcp` advertises the four content tools. The skeleton test `ContentMcpApplicationTests` was updated to assert the provider is present rather than that the catalog is empty. +- **Reason:** spec 012 (`ContentMcpToolProvider`) is the planned step that adds the content tools; the skeleton's "empty catalog" was only ever a temporary property of the pre-tools state. diff --git a/docs/specs/012-content-mcp-tools/steps.md b/docs/specs/012-content-mcp-tools/steps.md new file mode 100644 index 0000000..b29133f --- /dev/null +++ b/docs/specs/012-content-mcp-tools/steps.md @@ -0,0 +1,51 @@ +# Implementation Steps: Content MCP Tools + +## Step 1: `ContentMcpToolProvider` + +- [x] `@Component implements McpToolProvider` (gated on `openelements.mcp.enabled`), auto-aggregated by `McpServerConfig` +- [x] `toolSpecifications()` returns the four tools built with `McpTools.tool` + `McpToolSupport.spec` +- [x] `search_content` (query required; locale/source/category filters; paging) → `ContentSearchService.search` +- [x] `list_posts` (locale/source/category/since; paging) → `ContentSearchService.listPosts` +- [x] `get_post` (url or id; neither → `IllegalArgumentException`; absent → `NoSuchElementException`) → `getByUrlOrId` +- [x] `list_categories` (source/locale) → `categoryFacets` +- [x] Paging via `McpPaging.resolvePage/resolveSize`; bootstrap guard throws `McpUnavailableException` for search/list +- [x] Tool logic extracted to package-private methods for direct unit testing + +**Related behaviors:** all + +--- + +## Step 2: Tests + +- [x] `ContentMcpToolProviderTest` (capturing `ContentSearchService` subclass + real `McpToolSupport`/`McpPaging`): four tools registered; per-tool delegation, filter pass-through, size clamp, missing-arg/not-found/unavailable errors + +**Acceptance criteria:** +- [x] All tests pass (`mvn test`); build green + +--- + +## Step 3: Drift + dependency fixes + +- [x] Update spec 001's `ContentMcpApplicationTests` — the skeleton asserted an empty `McpToolProvider` catalog; a provider now exists. Drift recorded in `docs/specs/001-project-skeleton/behaviors.md`. +- [x] Pin `spring-services` to `1.3.0-20260712.175350-13` (the floating `1.3.0-SNAPSHOT` published a broken, dependency-less `-14` POM that broke the build). + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Four tools appear on /mcp | Backend | Step 2 (`fourToolsAreRegistered`) | +| search_content returns highlighted hits | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) + `ContentSearchServiceTest` (spec 011 highlighting) | +| Missing required query is rejected | Backend | Step 2 (`searchWithoutQueryThrows`) | +| Filters are passed through | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) | +| Paging is honored and clamped | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) | +| list_posts lists newest first / since filter | Backend | Step 2 (`listPostsFilterAndAvailability`) + spec 011 sort | +| get_post by url / id returns full post | Backend | Step 2 (`getPostByUrl`) | +| Neither url nor id is an error | Backend | Step 2 (`getPostWithoutArgsThrows`) | +| Unknown post is not-found | Backend | Step 2 (`getPostUnknownIsNotFound`) | +| list_categories returns counts / scoped | Backend | Step 2 (`listCategoriesDelegates`) | +| Tool reports unavailable during bootstrap | Backend | Step 2 (`searchUnavailableWhileBootstrapping`, `listPostsFilterAndAvailability`) | +| Each tool call is access-logged | Backend | Delegated to `McpToolSupport.spec` (library); tools are built via it. | + +All scenarios are backend; there is no frontend in this spec. diff --git a/pom.xml b/pom.xml index 43795cb..ebc72d2 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,10 @@ - 1.3.0-SNAPSHOT + + 1.3.0-20260712.175350-13 1.18.3 0.8.13 diff --git a/src/main/java/com/openelements/content/ContentMcpToolProvider.java b/src/main/java/com/openelements/content/ContentMcpToolProvider.java new file mode 100644 index 0000000..b4ead87 --- /dev/null +++ b/src/main/java/com/openelements/content/ContentMcpToolProvider.java @@ -0,0 +1,143 @@ +package com.openelements.content; + +import static com.openelements.spring.base.mcp.McpTools.integer; +import static com.openelements.spring.base.mcp.McpTools.paginationProps; +import static com.openelements.spring.base.mcp.McpTools.prop; +import static com.openelements.spring.base.mcp.McpTools.requiredString; +import static com.openelements.spring.base.mcp.McpTools.string; +import static com.openelements.spring.base.mcp.McpTools.tool; + +import com.openelements.spring.base.mcp.McpPaging; +import com.openelements.spring.base.mcp.McpToolProvider; +import com.openelements.spring.base.mcp.McpToolSupport; +import com.openelements.spring.base.mcp.McpUnavailableException; +import com.openelements.spring.base.services.search.SearchReadinessState; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Exposes the four content tools on the {@code /mcp} endpoint by implementing the library + * {@link McpToolProvider} (which {@code McpServerConfig} auto-aggregates). The tools are thin + * adapters over {@link ContentSearchService}; schemas, argument parsing, paging, access logging, and + * JSON-RPC error mapping are handled by the house helpers. + * + *

Error mapping (via {@link McpToolSupport#spec}): {@link IllegalArgumentException} → + * invalid-argument, {@link NoSuchElementException} → not-found, {@link McpUnavailableException} → + * temporary-unavailable. Search and list report unavailable while the index is bootstrapping. + * + *

Only created when the MCP server is enabled. + */ +@Component +@ConditionalOnProperty(prefix = "openelements.mcp", name = "enabled", havingValue = "true", matchIfMissing = true) +public class ContentMcpToolProvider implements McpToolProvider { + + private final ContentSearchService searchService; + private final McpToolSupport support; + private final McpPaging paging; + private final SearchReadinessState readinessState; + + public ContentMcpToolProvider(ContentSearchService searchService, McpToolSupport support, + McpPaging paging, SearchReadinessState readinessState) { + this.searchService = searchService; + this.support = support; + this.paging = paging; + this.readinessState = readinessState; + } + + @Override + public List toolSpecifications() { + return List.of(searchContent(), listPosts(), getPost(), listCategories()); + } + + // ---- tool definitions ---- + + private SyncToolSpecification searchContent() { + Map props = new LinkedHashMap<>(paginationProps()); + props.put("query", prop("string", "Search query (required).")); + props.put("locale", prop("string", "Filter by locale, e.g. en or de.")); + props.put("source", prop("string", "Filter by source id.")); + props.put("category", prop("string", "Filter by category.")); + Tool tool = tool("search_content", + "Full-text search across the indexed content; returns highlighted snippets.", + props, List.of("query")); + return support.spec(tool, this::searchContentLogic); + } + + private SyncToolSpecification listPosts() { + Map props = new LinkedHashMap<>(paginationProps()); + props.put("locale", prop("string", "Filter by locale, e.g. en or de.")); + props.put("source", prop("string", "Filter by source id.")); + props.put("category", prop("string", "Filter by category.")); + props.put("since", prop("string", "Only posts published on/after this ISO date (YYYY-MM-DD).")); + Tool tool = tool("list_posts", + "List indexed posts, newest first.", props, List.of()); + return support.spec(tool, this::listPostsLogic); + } + + private SyncToolSpecification getPost() { + Map props = new LinkedHashMap<>(); + props.put("url", prop("string", "The post URL (provide url or id).")); + props.put("id", prop("string", "The post id (provide url or id).")); + Tool tool = tool("get_post", + "Get the full content and metadata of a single post by url or id.", props, List.of()); + return support.spec(tool, this::getPostLogic); + } + + private SyncToolSpecification listCategories() { + Map props = new LinkedHashMap<>(); + props.put("source", prop("string", "Filter by source id.")); + props.put("locale", prop("string", "Filter by locale, e.g. en or de.")); + Tool tool = tool("list_categories", + "List content categories with their document counts.", props, List.of()); + return support.spec(tool, this::listCategoriesLogic); + } + + // ---- tool logic (package-private for direct unit testing) ---- + + Object searchContentLogic(Map args) { + requireReady(); + ContentFilters filters = new ContentFilters( + string(args, "source"), string(args, "locale"), string(args, "category"), null); + return searchService.search( + requiredString(args, "query"), filters, + paging.resolvePage(integer(args, "page")), paging.resolveSize(integer(args, "size"))); + } + + Object listPostsLogic(Map args) { + requireReady(); + ContentFilters filters = new ContentFilters( + string(args, "source"), string(args, "locale"), string(args, "category"), string(args, "since")); + return searchService.listPosts( + filters, paging.resolvePage(integer(args, "page")), paging.resolveSize(integer(args, "size"))); + } + + Object getPostLogic(Map args) { + String url = string(args, "url"); + String id = string(args, "id"); + if (isBlank(url) && isBlank(id)) { + throw new IllegalArgumentException("Either 'url' or 'id' is required"); + } + return searchService.getByUrlOrId(url, id) + .orElseThrow(() -> new NoSuchElementException("No post found for the given url/id")); + } + + Object listCategoriesLogic(Map args) { + return searchService.categoryFacets(string(args, "source"), string(args, "locale")); + } + + private void requireReady() { + if (readinessState.isBootstrapping()) { + throw new McpUnavailableException("Content index is still bootstrapping; try again shortly"); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/test/java/com/openelements/content/ContentMcpApplicationTests.java b/src/test/java/com/openelements/content/ContentMcpApplicationTests.java index 98206f2..ef12a85 100644 --- a/src/test/java/com/openelements/content/ContentMcpApplicationTests.java +++ b/src/test/java/com/openelements/content/ContentMcpApplicationTests.java @@ -58,8 +58,8 @@ void schedulingIsEnabled() { } @Test - @DisplayName("the /mcp server is exposed, advertises server name/version, and has no content tools yet") - void mcpEndpointIsExposedWithEmptyContentToolCatalog() { + @DisplayName("the /mcp server is exposed and advertises the configured server name/version") + void mcpEndpointIsExposed() { McpSyncServer mcpServer = context.getBean(McpSyncServer.class); assertThat(mcpServer).isNotNull(); @@ -70,7 +70,9 @@ void mcpEndpointIsExposedWithEmptyContentToolCatalog() { assertThat(mcpProperties.serverName()).isEqualTo("Open Elements Content MCP"); assertThat(mcpProperties.serverVersion()).isEqualTo("0.1.0"); - // No content-specific tools are registered yet (they arrive in spec 012). - assertThat(context.getBeansOfType(McpToolProvider.class)).isEmpty(); + // Drift (spec 012): the original skeleton asserted an empty McpToolProvider catalog; the + // content tools are now registered, so a ContentMcpToolProvider is present. + assertThat(context.getBeansOfType(McpToolProvider.class)) + .containsKey("contentMcpToolProvider"); } } diff --git a/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java b/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java new file mode 100644 index 0000000..09e2a1f --- /dev/null +++ b/src/test/java/com/openelements/content/ContentMcpToolProviderTest.java @@ -0,0 +1,204 @@ +package com.openelements.content; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openelements.spring.base.mcp.McpPaging; +import com.openelements.spring.base.mcp.McpProperties; +import com.openelements.spring.base.mcp.McpToolSupport; +import com.openelements.spring.base.mcp.McpUnavailableException; +import com.openelements.spring.base.services.search.MeilisearchClient; +import com.openelements.spring.base.services.search.MeilisearchProperties; +import com.openelements.spring.base.services.search.SearchReadinessState; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ContentMcpToolProvider}: tool registration and each tool's logic (argument + * parsing, delegation, paging clamp, and error mapping) via a capturing {@link ContentSearchService} + * subclass and the real MCP helper beans. No mocks or live Meilisearch. + */ +@DisplayName("Content MCP tool provider") +class ContentMcpToolProviderTest { + + private static final int MAX_PAGE_SIZE = 50; + + private final CapturingSearchService searchService = new CapturingSearchService(); + private final McpPaging paging = new McpPaging(new McpProperties(true, "n", "v", MAX_PAGE_SIZE, 10, null)); + private final McpToolSupport support = new McpToolSupport(paging, new ObjectMapper()); + private final SearchReadinessState readiness = new SearchReadinessState(); + private final ContentMcpToolProvider provider = + new ContentMcpToolProvider(searchService, support, paging, readiness); + + @BeforeEach + void markBootstrapComplete() { + readiness.markBootstrappingFinished(); + } + + private static Map args(Object... keyValues) { + Map map = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + @Test + @DisplayName("all four tools are registered with names") + void fourToolsAreRegistered() { + List names = provider.toolSpecifications().stream().map(spec -> spec.tool().name()).toList(); + + assertThat(names).containsExactlyInAnyOrder( + "search_content", "list_posts", "get_post", "list_categories"); + } + + @Test + @DisplayName("search_content passes filters through and clamps the page size") + void searchPassesFiltersAndClampsSize() { + searchContentResult(); + provider.searchContentLogic(args("query", "wallet", "locale", "de", "source", "open-elements", "size", 999)); + + assertThat(searchService.lastQuery).isEqualTo("wallet"); + assertThat(searchService.lastFilters.locale()).isEqualTo("de"); + assertThat(searchService.lastFilters.source()).isEqualTo("open-elements"); + assertThat(searchService.lastSize).isEqualTo(MAX_PAGE_SIZE); // clamped from 999 + } + + @Test + @DisplayName("search_content without a query is an invalid-argument error") + void searchWithoutQueryThrows() { + assertThatThrownBy(() -> provider.searchContentLogic(args())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("search_content reports unavailable while bootstrapping") + void searchUnavailableWhileBootstrapping() { + readiness.markBootstrappingStarted(); + + assertThatThrownBy(() -> provider.searchContentLogic(args("query", "wallet"))) + .isInstanceOf(McpUnavailableException.class); + } + + @Test + @DisplayName("list_posts passes the since filter and reports unavailable while bootstrapping") + void listPostsFilterAndAvailability() { + provider.listPostsLogic(args("since", "2026-01-01", "source", "hiero")); + assertThat(searchService.lastFilters.since()).isEqualTo("2026-01-01"); + assertThat(searchService.lastFilters.source()).isEqualTo("hiero"); + + readiness.markBootstrappingStarted(); + assertThatThrownBy(() -> provider.listPostsLogic(args())) + .isInstanceOf(McpUnavailableException.class); + } + + @Test + @DisplayName("get_post returns the full document by url") + void getPostByUrl() { + ContentDocument document = document(); + searchService.document = Optional.of(document); + + Object result = provider.getPostLogic(args("url", "https://ex.com/a")); + + assertThat(result).isEqualTo(document); + assertThat(searchService.lastUrl).isEqualTo("https://ex.com/a"); + } + + @Test + @DisplayName("get_post with neither url nor id is an invalid-argument error") + void getPostWithoutArgsThrows() { + assertThatThrownBy(() -> provider.getPostLogic(args())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("get_post for an unknown post is a not-found error") + void getPostUnknownIsNotFound() { + searchService.document = Optional.empty(); + + assertThatThrownBy(() -> provider.getPostLogic(args("id", "oe_missing"))) + .isInstanceOf(NoSuchElementException.class); + } + + @Test + @DisplayName("list_categories delegates with the source/locale filter") + void listCategoriesDelegates() { + searchService.categories = List.of(new CategoryCount("ai", 3)); + + Object result = provider.listCategoriesLogic(args("source", "hiero")); + + assertThat(result).isEqualTo(List.of(new CategoryCount("ai", 3))); + assertThat(searchService.lastSource).isEqualTo("hiero"); + } + + private void searchContentResult() { + searchService.hits = new SearchHits(List.of(new SearchHit("T", "u", "2026-01-01", "snip", 0.9)), 1); + } + + private static ContentDocument document() { + return new ContentDocument("oe_a", "open-elements", "en", "https://ex.com/a", + "T", "E", "body", "a", List.of("ai"), "2026-01-01", "2026-01-01", null); + } + + /** Capturing {@link ContentSearchService} test double recording arguments and returning canned data. */ + private static final class CapturingSearchService extends ContentSearchService { + private String lastQuery; + private ContentFilters lastFilters; + private int lastPage; + private int lastSize; + private String lastUrl; + private String lastId; + private String lastSource; + private String lastLocale; + private SearchHits hits = SearchHits.empty(); + private List categories = List.of(); + private Optional document = Optional.empty(); + + CapturingSearchService() { + super(new MeilisearchClient(props(), new ObjectMapper()), props()); + } + + private static MeilisearchProperties props() { + return new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10)); + } + + @Override + public SearchHits search(String query, ContentFilters filters, int page, int size) { + this.lastQuery = query; + this.lastFilters = filters; + this.lastPage = page; + this.lastSize = size; + return hits; + } + + @Override + public SearchHits listPosts(ContentFilters filters, int page, int size) { + this.lastFilters = filters; + this.lastPage = page; + this.lastSize = size; + return hits; + } + + @Override + public Optional getByUrlOrId(String url, String id) { + this.lastUrl = url; + this.lastId = id; + return document; + } + + @Override + public List categoryFacets(String source, String locale) { + this.lastSource = source; + this.lastLocale = locale; + return categories; + } + } +} From 2675413f56113b3d6ec1981b7505996225242b0f Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Mon, 13 Jul 2026 20:53:19 +0200 Subject: [PATCH 3/3] chore(spec-012): mark content-mcp-tools done Refs #23 --- docs/specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 66b86f4..b04f17b 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -17,7 +17,7 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done | | 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | #19 | done | | 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | #21 | done | -| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | in progress | +| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | done | | 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | — | open | | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | — | open | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | — | open |