Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/specs/001-project-skeleton/behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 51 additions & 0 deletions docs/specs/012-content-mcp-tools/steps.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/specs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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 |
Expand Down
5 changes: 4 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
</scm>

<properties>
<spring-services.version>1.3.0-SNAPSHOT</spring-services.version>
<!-- Pinned to a specific snapshot timestamp rather than the floating 1.3.0-SNAPSHOT:
the latest snapshot (-14) was published with a broken (dependency-less) POM. -13 is the
last known-good build. Move to a released 1.3.0 when available. -->
<spring-services.version>1.3.0-20260712.175350-13</spring-services.version>
<jsoup.version>1.18.3</jsoup.version>
<jacoco-maven-plugin.version>0.8.13</jacoco-maven-plugin.version>
</properties>
Expand Down
143 changes: 143 additions & 0 deletions src/main/java/com/openelements/content/ContentMcpToolProvider.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<SyncToolSpecification> toolSpecifications() {
return List.of(searchContent(), listPosts(), getPost(), listCategories());
}

// ---- tool definitions ----

private SyncToolSpecification searchContent() {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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");
}
}
Loading
Loading