Skip to content

Commit 59ada89

Browse files
Merge pull request #24 from OpenElementsLabs/feat/012-content-mcp-tools
Content MCP tools — the four tools on /mcp
2 parents 4ec8664 + 2675413 commit 59ada89

8 files changed

Lines changed: 431 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
5656
│ ├── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient
5757
│ ├── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex
5858
│ ├── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded)
59-
│ └── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
59+
│ ├── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
60+
│ └── ContentMcpToolProvider.java # McpToolProvider: the 4 tools on /mcp
6061
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
6162
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
6263
└── docs/
@@ -119,6 +120,15 @@ bootstrapping and to never overlap, with per-source fault isolation. On the read
119120
`multi-search` bodies (AND-combined `source`/`locale`/`categories`/`since` filters, `publishedDate:desc`
120121
tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML-safe snippets, plus
121122
`listPosts`, `getByUrlOrId`, and `categoryFacets`. Read-only; the scoped key comes in spec 013.
123+
`ContentMcpToolProvider` implements the library `McpToolProvider` (auto-aggregated by `McpServerConfig`)
124+
and exposes the four tools on `/mcp``search_content`, `list_posts`, `get_post`, `list_categories`
125+
as thin adapters over `ContentSearchService`, using the house helpers for schemas/paging/error mapping
126+
(invalid-argument / not-found / temporary-unavailable during bootstrap).
127+
128+
> **Build note:** `spring-services` is pinned to a specific snapshot timestamp
129+
> (`1.3.0-20260712.175350-13`) in `pom.xml`, not the floating `1.3.0-SNAPSHOT`, because a later
130+
> snapshot was published with a broken (dependency-less) POM. Keep it pinned until a released `1.3.0`
131+
> exists.
122132
123133
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
124134
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See

docs/specs/001-project-skeleton/behaviors.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,14 @@
4747
- **Given** `openelements.mcp.enabled=false`
4848
- **When** the app starts
4949
- **Then** no `/mcp` endpoint is exposed (the MCP server beans are not created)
50+
51+
---
52+
53+
## Drift Log
54+
55+
### 2026-07-13 — Caused by spec `012-content-mcp-tools`
56+
57+
- **Affected scenario:** /mcp endpoint is exposed
58+
- **Original behavior:** the `/mcp` server advertised the configured `server-name`/`server-version` with an **empty content-tool catalog** (no `McpToolProvider` beans registered).
59+
- **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.
60+
- **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.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Implementation Steps: Content MCP Tools
2+
3+
## Step 1: `ContentMcpToolProvider`
4+
5+
- [x] `@Component implements McpToolProvider` (gated on `openelements.mcp.enabled`), auto-aggregated by `McpServerConfig`
6+
- [x] `toolSpecifications()` returns the four tools built with `McpTools.tool` + `McpToolSupport.spec`
7+
- [x] `search_content` (query required; locale/source/category filters; paging) → `ContentSearchService.search`
8+
- [x] `list_posts` (locale/source/category/since; paging) → `ContentSearchService.listPosts`
9+
- [x] `get_post` (url or id; neither → `IllegalArgumentException`; absent → `NoSuchElementException`) → `getByUrlOrId`
10+
- [x] `list_categories` (source/locale) → `categoryFacets`
11+
- [x] Paging via `McpPaging.resolvePage/resolveSize`; bootstrap guard throws `McpUnavailableException` for search/list
12+
- [x] Tool logic extracted to package-private methods for direct unit testing
13+
14+
**Related behaviors:** all
15+
16+
---
17+
18+
## Step 2: Tests
19+
20+
- [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
21+
22+
**Acceptance criteria:**
23+
- [x] All tests pass (`mvn test`); build green
24+
25+
---
26+
27+
## Step 3: Drift + dependency fixes
28+
29+
- [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`.
30+
- [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).
31+
32+
---
33+
34+
## Behavior Coverage
35+
36+
| Scenario | Layer | Covered in Step |
37+
|----------|-------|-----------------|
38+
| Four tools appear on /mcp | Backend | Step 2 (`fourToolsAreRegistered`) |
39+
| search_content returns highlighted hits | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) + `ContentSearchServiceTest` (spec 011 highlighting) |
40+
| Missing required query is rejected | Backend | Step 2 (`searchWithoutQueryThrows`) |
41+
| Filters are passed through | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) |
42+
| Paging is honored and clamped | Backend | Step 2 (`searchPassesFiltersAndClampsSize`) |
43+
| list_posts lists newest first / since filter | Backend | Step 2 (`listPostsFilterAndAvailability`) + spec 011 sort |
44+
| get_post by url / id returns full post | Backend | Step 2 (`getPostByUrl`) |
45+
| Neither url nor id is an error | Backend | Step 2 (`getPostWithoutArgsThrows`) |
46+
| Unknown post is not-found | Backend | Step 2 (`getPostUnknownIsNotFound`) |
47+
| list_categories returns counts / scoped | Backend | Step 2 (`listCategoriesDelegates`) |
48+
| Tool reports unavailable during bootstrap | Backend | Step 2 (`searchUnavailableWhileBootstrapping`, `listPostsFilterAndAvailability`) |
49+
| Each tool call is access-logged | Backend | Delegated to `McpToolSupport.spec` (library); tools are built via it. |
50+
51+
All scenarios are backend; there is no frontend in this spec.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
1717
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done |
1818
| 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler``@Scheduled` incremental re-crawl | #19 | done |
1919
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService``multiSearch` + highlighting facade | #21 | done |
20-
| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | | open |
20+
| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | done |
2121
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index || open |
2222
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics || open |
2323
| 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources || open |

pom.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
</scm>
4343

4444
<properties>
45-
<spring-services.version>1.3.0-SNAPSHOT</spring-services.version>
45+
<!-- Pinned to a specific snapshot timestamp rather than the floating 1.3.0-SNAPSHOT:
46+
the latest snapshot (-14) was published with a broken (dependency-less) POM. -13 is the
47+
last known-good build. Move to a released 1.3.0 when available. -->
48+
<spring-services.version>1.3.0-20260712.175350-13</spring-services.version>
4649
<jsoup.version>1.18.3</jsoup.version>
4750
<jacoco-maven-plugin.version>0.8.13</jacoco-maven-plugin.version>
4851
</properties>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.openelements.content;
2+
3+
import static com.openelements.spring.base.mcp.McpTools.integer;
4+
import static com.openelements.spring.base.mcp.McpTools.paginationProps;
5+
import static com.openelements.spring.base.mcp.McpTools.prop;
6+
import static com.openelements.spring.base.mcp.McpTools.requiredString;
7+
import static com.openelements.spring.base.mcp.McpTools.string;
8+
import static com.openelements.spring.base.mcp.McpTools.tool;
9+
10+
import com.openelements.spring.base.mcp.McpPaging;
11+
import com.openelements.spring.base.mcp.McpToolProvider;
12+
import com.openelements.spring.base.mcp.McpToolSupport;
13+
import com.openelements.spring.base.mcp.McpUnavailableException;
14+
import com.openelements.spring.base.services.search.SearchReadinessState;
15+
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
16+
import io.modelcontextprotocol.spec.McpSchema.Tool;
17+
import java.util.LinkedHashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.NoSuchElementException;
21+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
22+
import org.springframework.stereotype.Component;
23+
24+
/**
25+
* Exposes the four content tools on the {@code /mcp} endpoint by implementing the library
26+
* {@link McpToolProvider} (which {@code McpServerConfig} auto-aggregates). The tools are thin
27+
* adapters over {@link ContentSearchService}; schemas, argument parsing, paging, access logging, and
28+
* JSON-RPC error mapping are handled by the house helpers.
29+
*
30+
* <p>Error mapping (via {@link McpToolSupport#spec}): {@link IllegalArgumentException} →
31+
* invalid-argument, {@link NoSuchElementException} → not-found, {@link McpUnavailableException} →
32+
* temporary-unavailable. Search and list report unavailable while the index is bootstrapping.
33+
*
34+
* <p>Only created when the MCP server is enabled.
35+
*/
36+
@Component
37+
@ConditionalOnProperty(prefix = "openelements.mcp", name = "enabled", havingValue = "true", matchIfMissing = true)
38+
public class ContentMcpToolProvider implements McpToolProvider {
39+
40+
private final ContentSearchService searchService;
41+
private final McpToolSupport support;
42+
private final McpPaging paging;
43+
private final SearchReadinessState readinessState;
44+
45+
public ContentMcpToolProvider(ContentSearchService searchService, McpToolSupport support,
46+
McpPaging paging, SearchReadinessState readinessState) {
47+
this.searchService = searchService;
48+
this.support = support;
49+
this.paging = paging;
50+
this.readinessState = readinessState;
51+
}
52+
53+
@Override
54+
public List<SyncToolSpecification> toolSpecifications() {
55+
return List.of(searchContent(), listPosts(), getPost(), listCategories());
56+
}
57+
58+
// ---- tool definitions ----
59+
60+
private SyncToolSpecification searchContent() {
61+
Map<String, Object> props = new LinkedHashMap<>(paginationProps());
62+
props.put("query", prop("string", "Search query (required)."));
63+
props.put("locale", prop("string", "Filter by locale, e.g. en or de."));
64+
props.put("source", prop("string", "Filter by source id."));
65+
props.put("category", prop("string", "Filter by category."));
66+
Tool tool = tool("search_content",
67+
"Full-text search across the indexed content; returns highlighted snippets.",
68+
props, List.of("query"));
69+
return support.spec(tool, this::searchContentLogic);
70+
}
71+
72+
private SyncToolSpecification listPosts() {
73+
Map<String, Object> props = new LinkedHashMap<>(paginationProps());
74+
props.put("locale", prop("string", "Filter by locale, e.g. en or de."));
75+
props.put("source", prop("string", "Filter by source id."));
76+
props.put("category", prop("string", "Filter by category."));
77+
props.put("since", prop("string", "Only posts published on/after this ISO date (YYYY-MM-DD)."));
78+
Tool tool = tool("list_posts",
79+
"List indexed posts, newest first.", props, List.of());
80+
return support.spec(tool, this::listPostsLogic);
81+
}
82+
83+
private SyncToolSpecification getPost() {
84+
Map<String, Object> props = new LinkedHashMap<>();
85+
props.put("url", prop("string", "The post URL (provide url or id)."));
86+
props.put("id", prop("string", "The post id (provide url or id)."));
87+
Tool tool = tool("get_post",
88+
"Get the full content and metadata of a single post by url or id.", props, List.of());
89+
return support.spec(tool, this::getPostLogic);
90+
}
91+
92+
private SyncToolSpecification listCategories() {
93+
Map<String, Object> props = new LinkedHashMap<>();
94+
props.put("source", prop("string", "Filter by source id."));
95+
props.put("locale", prop("string", "Filter by locale, e.g. en or de."));
96+
Tool tool = tool("list_categories",
97+
"List content categories with their document counts.", props, List.of());
98+
return support.spec(tool, this::listCategoriesLogic);
99+
}
100+
101+
// ---- tool logic (package-private for direct unit testing) ----
102+
103+
Object searchContentLogic(Map<String, Object> args) {
104+
requireReady();
105+
ContentFilters filters = new ContentFilters(
106+
string(args, "source"), string(args, "locale"), string(args, "category"), null);
107+
return searchService.search(
108+
requiredString(args, "query"), filters,
109+
paging.resolvePage(integer(args, "page")), paging.resolveSize(integer(args, "size")));
110+
}
111+
112+
Object listPostsLogic(Map<String, Object> args) {
113+
requireReady();
114+
ContentFilters filters = new ContentFilters(
115+
string(args, "source"), string(args, "locale"), string(args, "category"), string(args, "since"));
116+
return searchService.listPosts(
117+
filters, paging.resolvePage(integer(args, "page")), paging.resolveSize(integer(args, "size")));
118+
}
119+
120+
Object getPostLogic(Map<String, Object> args) {
121+
String url = string(args, "url");
122+
String id = string(args, "id");
123+
if (isBlank(url) && isBlank(id)) {
124+
throw new IllegalArgumentException("Either 'url' or 'id' is required");
125+
}
126+
return searchService.getByUrlOrId(url, id)
127+
.orElseThrow(() -> new NoSuchElementException("No post found for the given url/id"));
128+
}
129+
130+
Object listCategoriesLogic(Map<String, Object> args) {
131+
return searchService.categoryFacets(string(args, "source"), string(args, "locale"));
132+
}
133+
134+
private void requireReady() {
135+
if (readinessState.isBootstrapping()) {
136+
throw new McpUnavailableException("Content index is still bootstrapping; try again shortly");
137+
}
138+
}
139+
140+
private static boolean isBlank(String value) {
141+
return value == null || value.isBlank();
142+
}
143+
}

src/test/java/com/openelements/content/ContentMcpApplicationTests.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ void schedulingIsEnabled() {
5858
}
5959

6060
@Test
61-
@DisplayName("the /mcp server is exposed, advertises server name/version, and has no content tools yet")
62-
void mcpEndpointIsExposedWithEmptyContentToolCatalog() {
61+
@DisplayName("the /mcp server is exposed and advertises the configured server name/version")
62+
void mcpEndpointIsExposed() {
6363
McpSyncServer mcpServer = context.getBean(McpSyncServer.class);
6464
assertThat(mcpServer).isNotNull();
6565

@@ -70,7 +70,9 @@ void mcpEndpointIsExposedWithEmptyContentToolCatalog() {
7070
assertThat(mcpProperties.serverName()).isEqualTo("Open Elements Content MCP");
7171
assertThat(mcpProperties.serverVersion()).isEqualTo("0.1.0");
7272

73-
// No content-specific tools are registered yet (they arrive in spec 012).
74-
assertThat(context.getBeansOfType(McpToolProvider.class)).isEmpty();
73+
// Drift (spec 012): the original skeleton asserted an empty McpToolProvider catalog; the
74+
// content tools are now registered, so a ContentMcpToolProvider is present.
75+
assertThat(context.getBeansOfType(McpToolProvider.class))
76+
.containsKey("contentMcpToolProvider");
7577
}
7678
}

0 commit comments

Comments
 (0)