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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML-
`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).
(invalid-argument / not-found / temporary-unavailable during bootstrap). Security (spec 013):
`ContentConfig` registers a `ScopedKeySpec` bean so the library exchanges the master key for one scoped
to the content index with the minimal actions the service uses (it both reads and writes); MCP api-key
auth on `/mcp` defaults to **enabled** (`MCP_API_KEY_AUTH_ENABLED`), with the `dev` profile
(`application-dev.yaml`) disabling it for local use.

> **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
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ java -jar target/content-mcp-0.1.0-SNAPSHOT.jar
The app starts on port `8080` and exposes `/mcp`. It connects to Meilisearch at
`http://localhost:7700` by default (override via environment variables below).

By default `/mcp` requires an `X-API-Key`. For local development, run with the `dev` profile to
allow unauthenticated access:

```bash
java -jar target/content-mcp-0.1.0-SNAPSHOT.jar --spring.profiles.active=dev
```

## Test

```bash
Expand All @@ -62,14 +69,16 @@ Common overrides (all via environment variables):
| `SPRING_DATASOURCE_USERNAME` | `sa` | Datasource user |
| `SPRING_DATASOURCE_PASSWORD` | *(empty)* | Datasource password |
| `OAUTH2_JWK_SET_URI` | placeholder | JWK set URI for the library's OAuth2 resource server |
| `MCP_API_KEY_AUTH_ENABLED` | `true` | Require `X-API-Key` on `/mcp` (set `false` or use the `dev` profile locally) |

### Notes on the wiring

`spring-services` couples its MCP server to a JPA-backed api-key/user stack and depends on
`spring-boot-starter-data-jpa` non-optionally, so a datasource is mandatory. This app supplies
an **embedded H2** database that backs only those library tables — the content pipeline itself
keeps no relational state (its store is Meilisearch). The `/mcp` endpoint is secured by the
library defaults; real scoped-key authentication is added in a later spec (013). See
keeps no relational state (its store is Meilisearch). The `/mcp` endpoint requires an `X-API-Key` by
default (disable locally with the `dev` profile), and the runtime Meilisearch key is scoped to the
content index (spec 013). See
[`docs/specs/001-project-skeleton/design.md`](docs/specs/001-project-skeleton/design.md) for the
full rationale.

Expand Down
11 changes: 11 additions & 0 deletions docs/specs/001-project-skeleton/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,14 @@ open-elements:
- **Persistence of api-key/user data:** in-memory H2 resets on restart. If the library's api-key/user
data ever needs to persist for this app, configure a real datasource (the config already supports
overriding via `SPRING_DATASOURCE_*`). Revisit alongside spec 013 (auth).

---

## Drift Log

### 2026-07-13 — Caused by spec `013-scoped-search-key`

- **Affected element:** Configuration — `openelements.mcp.auth.api-key.enabled`
- **Original design:** the skeleton set `api-key.enabled: false` in `application.yaml` so the app could be probed locally without a key ("real auth handled in spec 013").
- **Current state:** api-key auth now defaults to **enabled** (`${MCP_API_KEY_AUTH_ENABLED:true}`); the `dev` profile (`application-dev.yaml`) disables it for local development. A `ScopedKeySpec` bean also scopes the runtime Meilisearch key to the content index.
- **Reason:** spec 013 is the planned step that establishes the real auth/security posture; the skeleton's open default was a temporary dev convenience.
44 changes: 44 additions & 0 deletions docs/specs/013-scoped-search-key/steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Implementation Steps: Scoped Search Key

## Step 1: `ScopedKeySpec` bean

- [x] `@Bean ScopedKeySpec contentScopedKey(...)` in `ContentConfig` (gated on `meilisearch.enabled`)
- [x] Scoped to `resolveIndex("content")` with the minimal action set the service uses (`CONTENT_INDEX_ACTIONS`: search + documents.add/delete/get + settings.get/update + indexes.get/create + tasks.get)
- [x] Library `MeilisearchScopedKeyInitializer` exchanges the master key for this scoped key at startup; degrades to the master key on failure

**Related behaviors:** scoped key minted at startup; runtime key limited to content index; query + startup writes succeed; degradation on failure

---

## Step 2: MCP auth posture

- [x] `application.yaml`: api-key auth on `/mcp` defaults to **enabled** (`MCP_API_KEY_AUTH_ENABLED`, default `true`)
- [x] `application-dev.yaml` (`dev` profile): disables api-key auth for local convenience

**Related behaviors:** API-key auth enforced in non-dev; dev profile allows unauthenticated local access

---

## Step 3: Tests

- [x] `ContentScopedKeyTest`: scoped-key bean targets the content index, grants the used actions, and is absent when Meilisearch is disabled
- [x] Full app-context build confirms the scoped-key initializer degrades gracefully when Meilisearch is unreachable

**Acceptance criteria:**
- [x] All tests pass (`mvn test`); build green

---

## Behavior Coverage

| Scenario | Layer | Covered in Step |
|----------|-------|-----------------|
| Scoped key is minted at startup | Backend (integration) | Delegated to the library `MeilisearchScopedKeyInitializer` (consumes the `ScopedKeySpec` bean); needs a live Meilisearch. Bean presence verified in Step 3. |
| Degradation when exchange fails | Backend | Step 3 — full context boots with the initializer against an unreachable Meilisearch (warn + fall back), verified by the existing app-context tests. |
| Runtime key is limited to the content index | Backend | Step 3 (`scopedKeyTargetsContentIndex`) — the spec scopes to one index; enforcement is Meilisearch's (live). |
| Query operations succeed under the scoped key | Backend | Step 3 (`scopedKeyGrantsUsedActions` includes `search`); live behavior is Meilisearch's. |
| Startup writes succeed under the granted actions | Backend | Step 3 (`scopedKeyGrantsUsedActions` includes the write actions); live behavior is Meilisearch's. |
| API-key auth enforced in non-dev | Backend | Step 2 — default `enabled=true` drives the library `McpSecurityConfig` filter chain (its enforcement is library-tested). |
| Dev profile allows unauthenticated local access | Backend | Step 2 (`application-dev.yaml`). |

All scenarios are backend; there is no frontend in this spec. The live key-exchange/enforcement scenarios are the library's responsibility and require a running Meilisearch, consistent with the project's established boundary. The exact Meilisearch action names are verified against a live instance; if the exchange fails the app degrades to the master key.
2 changes: 1 addition & 1 deletion docs/specs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
| 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 | done |
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | | open |
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | #25 | done |
| 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 |
| 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | — | open |
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/com/openelements/content/ContentConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.openelements.spring.base.services.search.IndexSettings;
import com.openelements.spring.base.services.search.MeilisearchProperties;
import com.openelements.spring.base.services.search.ScopedKeySpec;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand Down Expand Up @@ -47,4 +48,33 @@ IndexSettings contentIndexSettings(MeilisearchProperties meilisearchProperties)
List.of("source", "locale", "author", "categories", "publishedDate"),
List.of("publishedDate"));
}

/**
* The Meilisearch actions this service performs on the content index. The runtime key is scoped
* to exactly these — a large reduction from the master key (one index, no admin/key-management,
* no other indexes) even though the process writes (bootstrap + scheduler) as well as reads.
*/
static final List<String> CONTENT_INDEX_ACTIONS = List.of(
"search",
"documents.add", "documents.delete", "documents.get",
"settings.get", "settings.update",
"indexes.get", "indexes.create",
"tasks.get");

/**
* Scopes the runtime Meilisearch key to the content index with {@link #CONTENT_INDEX_ACTIONS}.
* The library's {@code MeilisearchScopedKeyInitializer} picks up this optional bean at startup,
* exchanges the master key for the scoped key, and switches the client to it; if the exchange
* fails (e.g. Meilisearch unreachable), it logs a warning and keeps the master key.
*
* @param meilisearchProperties library properties, used to resolve the prefixed index UID
* @return the scoped-key specification
*/
@Bean
@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true")
ScopedKeySpec contentScopedKey(MeilisearchProperties meilisearchProperties) {
return new ScopedKeySpec(
List.of(meilisearchProperties.resolveIndex("content")),
CONTENT_INDEX_ACTIONS);
}
}
8 changes: 8 additions & 0 deletions src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Local development profile: run with -Dspring.profiles.active=dev (or SPRING_PROFILES_ACTIVE=dev).
# Convenience only — never use in a shared or production deployment.
openelements:
mcp:
auth:
api-key:
# Allow unauthenticated local calls to /mcp so the endpoint can be probed without a key.
enabled: false
4 changes: 3 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ openelements:
server-version: "0.1.0"
auth:
api-key:
enabled: false # dev only; real auth handled in spec 013
# Secure default: /mcp requires X-API-Key. Disable for local development via the `dev`
# profile (application-dev.yaml) or MCP_API_KEY_AUTH_ENABLED=false.
enabled: ${MCP_API_KEY_AUTH_ENABLED:true}
meilisearch:
enabled: true
host: ${MEILISEARCH_HOST:http://localhost:7700}
Expand Down
52 changes: 52 additions & 0 deletions src/test/java/com/openelements/content/ContentScopedKeyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.openelements.content;

import static org.assertj.core.api.Assertions.assertThat;

import com.openelements.spring.base.services.search.MeilisearchProperties;
import com.openelements.spring.base.services.search.ScopedKeySpec;
import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

/**
* Verifies the {@link ScopedKeySpec} bean {@link ContentConfig} contributes — it scopes the runtime
* Meilisearch key to the content index with the actions the service actually uses.
*
* <p>The actual key exchange is performed by the library's {@code MeilisearchScopedKeyInitializer}
* against a live Meilisearch and is not reproduced here.
*/
@DisplayName("Content scoped key")
class ContentScopedKeyTest {

private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withBean(MeilisearchProperties.class,
() -> new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10)))
.withUserConfiguration(ContentConfig.class)
.withPropertyValues("openelements.meilisearch.enabled=true");

@Test
@DisplayName("the scoped key is limited to the content index")
void scopedKeyTargetsContentIndex() {
runner.run(context -> assertThat(context.getBean(ScopedKeySpec.class).indexes())
.containsExactly("content_content"));
}

@Test
@DisplayName("the scoped key grants search plus the write actions used at startup and refresh")
void scopedKeyGrantsUsedActions() {
runner.run(context -> assertThat(context.getBean(ScopedKeySpec.class).actions())
.contains("search", "documents.add", "documents.delete", "settings.update", "tasks.get"));
}

@Test
@DisplayName("no scoped key bean is created when Meilisearch is disabled")
void noScopedKeyWhenMeilisearchDisabled() {
new ApplicationContextRunner()
.withBean(MeilisearchProperties.class,
() -> new MeilisearchProperties("http://localhost:7700", "", "", Duration.ofSeconds(10)))
.withUserConfiguration(ContentConfig.class)
.withPropertyValues("openelements.meilisearch.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(ScopedKeySpec.class));
}
}
Loading