diff --git a/AGENTS.md b/AGENTS.md
index e3816cc1..e7ded147 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,12 +6,13 @@ DICE (Domain-Integrated Context Engineering) is a proposition-first knowledge su
| Module | What it owns |
|---|---|
-| `dice` | The entire domain: `Proposition` model, `PropositionStore`/`PropositionRepository` SPIs, extraction pipeline, revision/conflict detection, entity resolution, projectors (graph, Prolog, memory), graph and discovery query/retrieval, incremental analysis, in-memory and file-backed stores, tuProlog integration, REST endpoints |
+| `dice` | The entire domain: `Proposition` model, `PropositionStore`/`PropositionRepository` SPIs, extraction pipeline, revision/conflict detection, entity resolution, projectors (graph, Prolog, memory), graph and discovery query/retrieval, incremental analysis, in-memory and file-backed stores, tuProlog integration, REST endpoints, MCP tool surface (`DiceMcpTools`) |
| `dice-storage` | Drivine/Neo4j implementation of `PropositionRepository`, `ChunkHistoryStore`, and `DecayManager`; uses Kotlin 2.2 for the Drivine KSP-generated query DSL |
| `dice-storage-autoconfigure` | Spring Boot auto-configuration that wires the right backend based on `embabel.dice.store.type`, schedules the decay tick, and provides auto-configuration for the multi-signal duplicate collector (properties prefix `embabel.dice.collector`) |
| `dice-report` | Output projectors over propositions: rationale (why a fact is believed, with evidence), structured report, and surprising-link discovery |
| `dice-ingestion` | Ingestion SPI (artifacts → chunks) with a content-hash dedup ledger so the same source isn't extracted twice |
| `dice-integration-tests` | Test-only: the cross-feature end-to-end canonical-flow harness |
+| `dice-mcp-autoconfigure` | Spring Boot auto-configuration that exports `DiceMcpTools` over MCP via embabel-agent when `embabel.dice.mcp.enabled=true` |
## Build & test
@@ -65,6 +66,7 @@ The `dice` module is organized by responsibility:
| `com.embabel.dice.provenance` | `ProvenanceEntry`, `SourceLocator` — rich evidence links from propositions back to source material |
| `com.embabel.dice.query.oracle` | `Oracle`, `LlmOracle`, `PrologTools` — natural language question answering over propositions |
| `com.embabel.dice.web.rest` | Optional REST endpoints for the pipeline and memory; activated by `spring-webmvc` on the classpath |
+| `com.embabel.dice.mcp` | `DiceMcpTools`, `DiceMcpProfile` — simplified MCP/agent tool surface over propositions |
## Conventions
@@ -84,6 +86,7 @@ The `dice` module is organized by responsibility:
- **Adding or changing extraction logic** → `com.embabel.dice.proposition.extraction.LlmPropositionExtractor` and the Mustache prompt templates in `dice/src/main/resources/dice/`.
- **Wiring a new Spring Boot app** → `dice-storage-autoconfigure`, specifically `DiceStorageAutoConfiguration` (backend selection) and `DiceStoreProperties` (property keys). Set `embabel.dice.store.type=graph` for Neo4j.
+- **Exposing DICE over MCP** → `DiceMcpTools` in `com.embabel.dice.mcp` for the tool surface; `dice-mcp-autoconfigure` + `embabel-agent-starter-mcpserver` for zero-code export (`embabel.dice.mcp.enabled=true`).
- **Understanding the proposition data model** → `Proposition.kt` in `com.embabel.dice.proposition`. Every field is documented inline.
- **Adding a new entity resolver strategy** → implement `CandidateSearcher` in `com.embabel.dice.common.resolver.searcher`, then compose it into an `EscalatingEntityResolver`.
- **Writing integration tests against Neo4j** → look at `DrivinePropositionStoreIntegrationTest` in `dice-storage/src/test`; it shows the `@SpringBootTest` + Testcontainers pattern in use.
diff --git a/README.md b/README.md
index d4a92ef2..2de625bf 100644
--- a/README.md
+++ b/README.md
@@ -2412,6 +2412,56 @@ Everything is pushed into the database rather than scanned in memory:
> `dice-storage/HANDOFF.md` for architecture and `dice-storage/INTEGRATE-INTO-ASSISTANT.md` for a
> migration walkthrough.
+### MCP Server
+
+Expose DICE knowledge tools to any MCP-compatible client (Claude Desktop, Cursor, etc.)
+using embabel-agent's MCP server starter and the `dice-mcp-autoconfigure` module.
+
+```xml
+
+ com.embabel.dice
+ dice-mcp-autoconfigure
+ ${dice.version}
+
+
+ com.embabel.agent
+ embabel-agent-starter-mcpserver
+ ${embabel-agent.version}
+
+```
+
+```yaml
+embabel:
+ dice:
+ mcp:
+ enabled: true
+ profile: core # dice_recall, dice_list, dice_store, dice_get
+ # profile: extended # + dice_extract, dice_assert_entities when wired
+```
+
+**Core tools** (always available when a `PropositionRepository` is configured):
+
+| Tool | Description |
+|------|-------------|
+| `dice_recall` | Hybrid semantic + keyword search over propositions in a `context_id` |
+| `dice_list` | List active propositions for a context |
+| `dice_store` | Store a proposition directly |
+| `dice_get` | Fetch one proposition by id |
+
+**Extended tools** (exported when the host app wires the corresponding beans):
+
+| Tool | Requires |
+|------|----------|
+| `dice_extract` | `PropositionPipeline`, `EntityResolver`, `SchemaRegistry` |
+| `dice_assert_entities` | `EntityResolutionService` |
+
+Programmatic export without Spring Boot:
+
+```kotlin
+val tools = DiceMcpTools(propositionRepository)
+val export = McpToolExport.fromToolObject(ToolObject(objects = listOf(tools)))
+```
+
### API Key Security
DICE provides API key authentication for the REST endpoints. Enable it via configuration:
diff --git a/dice-mcp-autoconfigure/pom.xml b/dice-mcp-autoconfigure/pom.xml
new file mode 100644
index 00000000..fb5384eb
--- /dev/null
+++ b/dice-mcp-autoconfigure/pom.xml
@@ -0,0 +1,81 @@
+
+
+ 4.0.0
+
+ com.embabel.dice
+ dice-parent
+ 0.1.1-SNAPSHOT
+
+ dice-mcp-autoconfigure
+ jar
+ Dice MCP Autoconfigure
+ Spring Boot auto-configuration that exports DICE tools over MCP via embabel-agent
+
+
+
+ com.embabel.dice
+ dice
+
+
+
+ com.embabel.agent
+ embabel-agent-mcpserver
+ true
+
+
+
+ com.embabel.agent
+ embabel-agent-api
+ provided
+
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ com.embabel.agent
+ embabel-agent-test-common
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+
+
+ -Xjsr305=strict
+ -Xjvm-default=all
+
+
+
+
+
+
+
diff --git a/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpAutoConfiguration.kt b/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpAutoConfiguration.kt
new file mode 100644
index 00000000..583c1ee7
--- /dev/null
+++ b/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpAutoConfiguration.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp.autoconfigure
+
+import com.embabel.agent.api.tool.ToolObject
+import com.embabel.agent.mcpserver.McpToolExport
+import com.embabel.dice.common.EntityResolver
+import com.embabel.dice.common.SchemaRegistry
+import com.embabel.dice.entity.EntityResolutionService
+import com.embabel.dice.mcp.DiceMcpTools
+import com.embabel.dice.pipeline.PropositionPipeline
+import com.embabel.dice.proposition.PropositionRepository
+import org.slf4j.LoggerFactory
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.context.annotation.Bean
+
+/**
+ * Registers [DiceMcpTools] and exports them as MCP tools when embabel-agent's MCP server is present.
+ *
+ * Typical application dependencies:
+ * ```xml
+ *
+ * com.embabel.dice
+ * dice-mcp-autoconfigure
+ *
+ *
+ * com.embabel.agent
+ * embabel-agent-starter-mcpserver
+ *
+ * ```
+ *
+ * ```yaml
+ * embabel:
+ * dice:
+ * mcp:
+ * enabled: true
+ * profile: core # or extended
+ * ```
+ */
+@AutoConfiguration
+@ConditionalOnClass(McpToolExport::class)
+@ConditionalOnProperty(prefix = "embabel.dice.mcp", name = ["enabled"], havingValue = "true")
+@EnableConfigurationProperties(DiceMcpProperties::class)
+open class DiceMcpAutoConfiguration {
+
+ private val logger = LoggerFactory.getLogger(DiceMcpAutoConfiguration::class.java)
+
+ @Bean
+ @ConditionalOnBean(PropositionRepository::class)
+ @ConditionalOnMissingBean(DiceMcpTools::class)
+ open fun diceMcpTools(
+ repository: PropositionRepository,
+ pipeline: ObjectProvider,
+ entityResolver: ObjectProvider,
+ schemaRegistry: ObjectProvider,
+ entityResolutionService: ObjectProvider,
+ properties: DiceMcpProperties,
+ ): DiceMcpTools = DiceMcpTools(
+ repository = repository,
+ pipeline = pipeline.ifAvailable,
+ entityResolver = entityResolver.ifAvailable,
+ schemaRegistry = schemaRegistry.ifAvailable,
+ entityResolutionService = entityResolutionService.ifAvailable,
+ minConfidence = properties.minConfidence,
+ defaultLimit = properties.defaultLimit,
+ )
+
+ @Bean("diceMcpToolExport")
+ @ConditionalOnBean(DiceMcpTools::class)
+ @ConditionalOnMissingBean(name = ["diceMcpToolExport"])
+ open fun diceMcpToolExport(
+ tools: DiceMcpTools,
+ properties: DiceMcpProperties,
+ ): McpToolExport {
+ val allowed = properties.profile.toolNames(
+ pipelineAvailable = tools.pipelineAvailable(),
+ entityResolutionAvailable = tools.entityResolutionAvailable(),
+ )
+ logger.info(
+ "Exporting DICE MCP tools (profile={}, count={}): {}",
+ properties.profile,
+ allowed.size,
+ allowed.sorted(),
+ )
+ return McpToolExport.fromToolObject(
+ ToolObject(
+ objects = listOf(tools),
+ filter = { name -> name in allowed },
+ ),
+ )
+ }
+}
diff --git a/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpProperties.kt b/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpProperties.kt
new file mode 100644
index 00000000..1ecfec3c
--- /dev/null
+++ b/dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpProperties.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp.autoconfigure
+
+import com.embabel.dice.mcp.DiceMcpProfile
+import org.springframework.boot.context.properties.ConfigurationProperties
+
+/**
+ * Configuration for exporting DICE as MCP tools.
+ *
+ * Requires `embabel-agent-starter-mcpserver` (or `embabel-agent-mcpserver`) on the classpath
+ * and `embabel.dice.mcp.enabled=true`.
+ */
+@ConfigurationProperties(prefix = "embabel.dice.mcp")
+data class DiceMcpProperties(
+ /** Master switch. Default false so MCP export is opt-in. */
+ val enabled: Boolean = false,
+ /** Tool surface: [DiceMcpProfile.CORE] (4 tools) or [DiceMcpProfile.EXTENDED]. */
+ val profile: DiceMcpProfile = DiceMcpProfile.CORE,
+ /** Minimum effective confidence for recall/list tools (0.0–1.0). */
+ val minConfidence: Double = 0.5,
+ /** Default result limit for recall/list tools. */
+ val defaultLimit: Int = 10,
+) {
+ init {
+ require(minConfidence in 0.0..1.0) { "embabel.dice.mcp.min-confidence must be between 0.0 and 1.0" }
+ require(defaultLimit > 0) { "embabel.dice.mcp.default-limit must be positive" }
+ }
+}
diff --git a/dice-mcp-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/dice-mcp-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000..33211003
--- /dev/null
+++ b/dice-mcp-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.embabel.dice.mcp.autoconfigure.DiceMcpAutoConfiguration
diff --git a/dice/AGENTS.md b/dice/AGENTS.md
index 364fa122..6fa7da13 100644
--- a/dice/AGENTS.md
+++ b/dice/AGENTS.md
@@ -76,6 +76,7 @@ These map onto the lifecycle in [proposition-lifecycle](../docs/design/propositi
| `query.discovery` | `RetrievalRouter`, `DiscoveryQuery`, `RetrievalMode`, discovery DTOs — mode-routed retrieval entry point |
| `temporal` | `TemporalMetadata` — bitemporal valid/observed windows, explicit retraction |
| `agent` | `Memory`, `MemoryRetriever` (agent-facing view), `ProvenanceResolver`; **`DiscoveryTools`** — `@LlmTool`-annotated tools wrapping `RetrievalRouter` (query propositions, graph path, why-explain, projection health, collector dry-run) with context baked in at construction so an agent can't cross context boundaries; **`GraphQueryTools`** — `@LlmTool`-annotated tools wrapping the `GraphQuery` facade (entity neighbourhood, path between entities, why-explain) |
+| `mcp` | `DiceMcpTools`, `DiceMcpProfile` — simplified MCP/agent tool surface (`dice_recall`, `dice_list`, `dice_store`, `dice_get`, optional `dice_extract` / `dice_assert_entities`) |
| `web.rest` | `PropositionPipelineController`, `MemoryController`, **`DiscoveryController`** — REST surface for discovery operations (`/api/v1/contexts/{contextId}/discovery`; routes query, path, why, projection health, and collector dry-run; context comes from the URL path only), API key security — optional, activated by `spring-webmvc` |
| `operations` | `PropositionAbstractor`, `PropositionContraster` — higher-level proposition management |
| `operations.consolidation` | The dream-loop steps as composable passes: `ConsolidationPass`/`ConsolidationPassResult`, `SessionConsolidationPass`, `AbstractionPass`, `ContradictionResolutionPass`, `DecaySweepPass` |
diff --git a/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpProfile.kt b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpProfile.kt
new file mode 100644
index 00000000..104deb4b
--- /dev/null
+++ b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpProfile.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp
+
+/**
+ * Tool surface exposed over MCP.
+ *
+ * Mirrors the core/extended split used by other memory MCP servers (e.g. neo4j-agent-memory):
+ * a small read/write cycle for everyday recall, plus extraction and entity assertion when the
+ * host application wires the heavier pipelines.
+ *
+ * ## Why `context_id` on every tool
+ *
+ * Unlike the in-process [com.embabel.dice.agent.Memory] tool — which bakes [com.embabel.agent.core.ContextId]
+ * in at construction time — MCP clients are stateless and may serve many sessions. Requiring an
+ * explicit `context_id` on each call prevents cross-tenant / cross-session knowledge leakage,
+ * the same motivation as context-scoped [com.embabel.dice.incremental.ChunkHistoryStore] keys
+ * introduced in #6.
+ *
+ * ## Profiles
+ *
+ * - [CORE] — recall, list, store, get. Safe to expose when only a [com.embabel.dice.proposition.PropositionRepository] exists.
+ * - [EXTENDED] — adds [DiceMcpTools.EXTRACT] and [DiceMcpTools.ASSERT_ENTITIES] only when the
+ * corresponding Spring beans are present, so MCP export never advertises tools that would fail at runtime.
+ */
+enum class DiceMcpProfile {
+ /** Essential recall and persistence: search, list, store, get-by-id. */
+ CORE,
+
+ /** [CORE] plus text extraction and entity assertion when wired. */
+ EXTENDED,
+ ;
+
+ /**
+ * Tool names to export for this profile.
+ *
+ * Extended optional tools are included only when their backing services are configured,
+ * so the MCP `tools/list` response matches what can actually be invoked.
+ */
+ fun toolNames(
+ pipelineAvailable: Boolean = false,
+ entityResolutionAvailable: Boolean = false,
+ ): Set {
+ val names = linkedSetOf(
+ DiceMcpTools.RECALL,
+ DiceMcpTools.LIST,
+ DiceMcpTools.STORE,
+ DiceMcpTools.GET,
+ )
+ if (this == EXTENDED) {
+ if (pipelineAvailable) names += DiceMcpTools.EXTRACT
+ if (entityResolutionAvailable) names += DiceMcpTools.ASSERT_ENTITIES
+ }
+ return names
+ }
+}
diff --git a/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpSupport.kt b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpSupport.kt
new file mode 100644
index 00000000..d8a390ea
--- /dev/null
+++ b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpSupport.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp
+
+import com.embabel.agent.api.tool.Tool
+import com.embabel.agent.core.ContextId
+import com.embabel.dice.agent.MemoryRetriever
+import com.embabel.dice.proposition.Proposition
+import com.embabel.dice.proposition.PropositionQuery
+import com.embabel.dice.proposition.PropositionRepository
+import com.embabel.dice.proposition.PropositionStatus
+
+/**
+ * Shared retrieval and formatting helpers for [DiceMcpTools].
+ *
+ * Delegates hybrid recall to [MemoryRetriever] so MCP and in-process agent tools stay aligned
+ * on vector + keyword + entity-expansion behaviour without duplicating ranking logic.
+ */
+internal object DiceMcpSupport {
+
+ /**
+ * Base query for MCP recall/list: scoped to one [contextId], filtered to [PropositionStatus.ACTIVE]
+ * propositions at or above [minConfidence] effective confidence.
+ *
+ * STALE / SUPERSEDED / CONTRADICTED propositions are excluded by default — the same guard
+ * [com.embabel.dice.agent.Memory] applies before results reach an LLM.
+ */
+ fun baseQuery(contextId: String, minConfidence: Double): PropositionQuery =
+ PropositionQuery.forContextId(ContextId(contextId))
+ .withMinEffectiveConfidence(minConfidence)
+ .withStatuses(setOf(PropositionStatus.ACTIVE))
+
+ fun recall(
+ repository: PropositionRepository,
+ contextId: String,
+ query: String?,
+ limit: Int,
+ minConfidence: Double,
+ ): String {
+ val base = baseQuery(contextId, minConfidence)
+ val retriever = MemoryRetriever(repository, provenanceResolver = null, topic = contextId, eagerIds = emptySet())
+ val result = if (query.isNullOrBlank()) {
+ retriever.listAll(base, limit)
+ } else {
+ retriever.search(query.trim(), base, limit)
+ }
+ return (result as? Tool.Result.Text)?.content ?: result.toString()
+ }
+
+ fun formatProposition(proposition: Proposition): String =
+ buildString {
+ append("id=${proposition.id}")
+ append(" | confidence=${"%.2f".format(proposition.effectiveConfidence())}")
+ append(" | ${proposition.text}")
+ if (proposition.mentions.isNotEmpty()) {
+ val entities = proposition.mentions.joinToString("; ") { mention ->
+ "${mention.span} (${mention.type})"
+ }
+ append(" | entities: $entities")
+ }
+ }
+}
diff --git a/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpTools.kt b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpTools.kt
new file mode 100644
index 00000000..a8b29cbf
--- /dev/null
+++ b/dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpTools.kt
@@ -0,0 +1,280 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp
+
+import com.embabel.agent.api.annotation.LlmTool
+import com.embabel.agent.api.tool.Tool
+import com.embabel.agent.core.ContextId
+import com.embabel.agent.rag.model.Chunk
+import com.embabel.dice.common.EntityResolver
+import com.embabel.dice.common.SchemaRegistry
+import com.embabel.dice.common.SourceAnalysisContext
+import com.embabel.dice.entity.EntityAssertion
+import com.embabel.dice.entity.EntityAssertionRequest
+import com.embabel.dice.entity.EntityResolutionService
+import com.embabel.dice.entity.EntityResolutionTools
+import com.embabel.dice.pipeline.PropositionPipeline
+import com.embabel.dice.proposition.Proposition
+import com.embabel.dice.proposition.PropositionRepository
+import com.embabel.dice.proposition.revision.RevisionResult
+
+/**
+ * MCP-oriented tools for DICE with deliberately simple parameters.
+ *
+ * Each tool takes a `context_id` so external MCP clients (Claude Desktop, Cursor, etc.)
+ * can scope knowledge without baking context into server configuration. Export these
+ * through embabel-agent's [com.embabel.agent.mcpserver.McpToolExport]:
+ *
+ * ```kotlin
+ * @Bean
+ * fun diceMcpTools(repository: PropositionRepository): DiceMcpTools =
+ * DiceMcpTools(repository)
+ *
+ * @Bean
+ * fun diceMcpExport(tools: DiceMcpTools): McpToolExport =
+ * McpToolExport.fromToolObject(ToolObject(objects = listOf(tools)))
+ * ```
+ *
+ * Or add `dice-mcp-autoconfigure` and `embabel-agent-starter-mcpserver` to auto-wire both.
+ *
+ * @param repository proposition store (required)
+ * @param pipeline optional extraction pipeline for [extract]
+ * @param entityResolver required by [extract] when a pipeline is configured
+ * @param schemaRegistry required by [extract] when a pipeline is configured
+ * @param entityResolutionService optional service backing [assertEntities]
+ * @param minConfidence minimum effective confidence for recall/list
+ * @param defaultLimit default result cap for recall/list
+ */
+class DiceMcpTools(
+ private val repository: PropositionRepository,
+ private val pipeline: PropositionPipeline? = null,
+ private val entityResolver: EntityResolver? = null,
+ private val schemaRegistry: SchemaRegistry? = null,
+ private val entityResolutionService: EntityResolutionService? = null,
+ private val minConfidence: Double = DEFAULT_MIN_CONFIDENCE,
+ private val defaultLimit: Int = DEFAULT_LIMIT,
+) {
+
+ /**
+ * Hybrid semantic + keyword recall over stored propositions in a context.
+ */
+ @LlmTool(
+ name = RECALL,
+ description = "Search stored knowledge (propositions) in a DICE context. " +
+ "Pass a natural-language query to run hybrid semantic + keyword retrieval. " +
+ "Omit query to list memories ordered by confidence.",
+ )
+ fun recall(
+ @LlmTool.Param(description = "Context to search within (session, user, or tenant id).")
+ contextId: String,
+ @LlmTool.Param(description = "What to recall, in natural language. Omit to list all memories.")
+ query: String? = null,
+ @LlmTool.Param(description = "Maximum results (default 10).")
+ limit: Int = defaultLimit,
+ ): String = DiceMcpSupport.recall(
+ repository = repository,
+ contextId = contextId,
+ query = query,
+ limit = limit.coerceAtLeast(1),
+ minConfidence = minConfidence,
+ )
+
+ /**
+ * List active propositions for a context, ordered by effective confidence.
+ */
+ @LlmTool(
+ name = LIST,
+ description = "List stored propositions for a DICE context, ordered by effective confidence.",
+ )
+ fun listMemories(
+ @LlmTool.Param(description = "Context to list.")
+ contextId: String,
+ @LlmTool.Param(description = "Maximum results (default 10).")
+ limit: Int = defaultLimit,
+ ): String {
+ val query = DiceMcpSupport.baseQuery(contextId, minConfidence)
+ .orderedByEffectiveConfidence()
+ .withLimit(limit.coerceAtLeast(1))
+ val propositions = repository.query(query)
+ if (propositions.isEmpty()) {
+ return "No memories in context '$contextId'."
+ }
+ return buildString {
+ appendLine("Found ${propositions.size} memories in context '$contextId':")
+ propositions.forEachIndexed { index, proposition ->
+ appendLine("${index + 1}. ${DiceMcpSupport.formatProposition(proposition)}")
+ }
+ }.trimEnd()
+ }
+
+ /**
+ * Store a proposition directly without running the extraction pipeline.
+ */
+ @LlmTool(
+ name = STORE,
+ description = "Store a natural-language proposition in a DICE context without running extraction.",
+ )
+ fun storeMemory(
+ @LlmTool.Param(description = "Context to store into.")
+ contextId: String,
+ @LlmTool.Param(description = "The fact to remember, in natural language.")
+ text: String,
+ @LlmTool.Param(description = "Confidence between 0 and 1 (default 0.8).")
+ confidence: Double = 0.8,
+ ): String {
+ require(text.isNotBlank()) { "text must not be blank" }
+ val proposition = Proposition(
+ contextId = ContextId(contextId),
+ text = text.trim(),
+ mentions = emptyList(),
+ confidence = confidence.coerceIn(0.0, 1.0),
+ )
+ val saved = repository.save(proposition)
+ return "Stored proposition ${saved.id}: ${saved.text}"
+ }
+
+ /**
+ * Fetch a single proposition by id within a context.
+ */
+ @LlmTool(
+ name = GET,
+ description = "Get one stored proposition by id within a DICE context.",
+ )
+ fun getProposition(
+ @LlmTool.Param(description = "Context the proposition belongs to.")
+ contextId: String,
+ @LlmTool.Param(description = "Proposition id returned by recall, list, or store.")
+ propositionId: String,
+ ): String {
+ val proposition = repository.findById(propositionId)
+ ?: return "No proposition with id '$propositionId'."
+ if (proposition.contextIdValue != contextId) {
+ return "Proposition '$propositionId' is not in context '$contextId'."
+ }
+ return DiceMcpSupport.formatProposition(proposition)
+ }
+
+ /**
+ * Run the proposition extraction pipeline on raw text.
+ */
+ @LlmTool(
+ name = EXTRACT,
+ description = "Extract propositions and resolve entities from raw text into a DICE context. " +
+ "Requires a configured PropositionPipeline on the server.",
+ )
+ fun extract(
+ @LlmTool.Param(description = "Context to extract into.")
+ contextId: String,
+ @LlmTool.Param(description = "Source text to analyse.")
+ text: String,
+ @LlmTool.Param(description = "Optional source id for provenance (defaults to 'mcp-extract').")
+ sourceId: String? = null,
+ ): String {
+ val activePipeline = pipeline
+ ?: error("dice_extract is not available: no PropositionPipeline configured")
+ val resolver = entityResolver
+ ?: error("dice_extract is not available: no EntityResolver configured")
+ val schemas = schemaRegistry
+ ?: error("dice_extract is not available: no SchemaRegistry configured")
+ require(text.isNotBlank()) { "text must not be blank" }
+
+ val chunk = Chunk.create(text = text.trim(), parentId = sourceId ?: "mcp-extract")
+ val context = SourceAnalysisContext(
+ schema = schemas.getOrDefault(null),
+ entityResolver = resolver,
+ contextId = ContextId(contextId),
+ )
+ val result = activePipeline.processChunk(chunk, context)
+ result.propositionsToPersist().forEach { repository.save(it) }
+
+ val propositions = result.propositions
+ if (propositions.isEmpty()) {
+ return "No propositions extracted from text in context '$contextId'."
+ }
+ return buildString {
+ appendLine("Extracted ${propositions.size} propositions in context '$contextId':")
+ propositions.forEachIndexed { index, proposition ->
+ appendLine("${index + 1}. ${DiceMcpSupport.formatProposition(proposition)}")
+ }
+ if (result.revisionResults.isNotEmpty()) {
+ appendLine()
+ append("Revision: ")
+ append("created=${result.revisionResults.count { it is RevisionResult.New }}, ")
+ append("merged=${result.revisionResults.count { it is RevisionResult.Merged }}, ")
+ append("reinforced=${result.revisionResults.count { it is RevisionResult.Reinforced }}, ")
+ append("contradicted=${result.revisionResults.count { it is RevisionResult.Contradicted }}")
+ }
+ }.trimEnd()
+ }
+
+ /**
+ * Assert structured entities (and optional relationships) into the knowledge graph.
+ */
+ @LlmTool(
+ name = ASSERT_ENTITIES,
+ description = "Assert entities into the knowledge graph with automatic resolution. " +
+ "Requires EntityResolutionService on the server.",
+ )
+ fun assertEntities(
+ @LlmTool.Param(description = "Entities to assert. Each needs a name; labels, description, and properties are optional.")
+ entities: List,
+ ): String {
+ val service = entityResolutionService
+ ?: error("dice_assert_entities is not available: no EntityResolutionService configured")
+ require(entities.isNotEmpty()) { "entities must not be empty" }
+ val result = service.resolve(EntityAssertionRequest(entities = entities))
+ return buildString {
+ appendLine("Asserted ${result.resolutions.size} entities:")
+ result.resolutions.forEach { resolution ->
+ appendLine("- ${resolution.name}: ${resolution.resolution} → ${resolution.entityId}")
+ }
+ }.trimEnd()
+ }
+
+ /** Whether [extract] can be exported for the current wiring. */
+ fun pipelineAvailable(): Boolean =
+ pipeline != null && entityResolver != null && schemaRegistry != null
+
+ /** Whether [assertEntities] can be exported for the current wiring. */
+ fun entityResolutionAvailable(): Boolean = entityResolutionService != null
+
+ companion object {
+ const val RECALL = "dice_recall"
+ const val LIST = "dice_list"
+ const val STORE = "dice_store"
+ const val GET = "dice_get"
+ const val EXTRACT = "dice_extract"
+ const val ASSERT_ENTITIES = "dice_assert_entities"
+
+ const val DEFAULT_MIN_CONFIDENCE = 0.5
+ const val DEFAULT_LIMIT = 10
+
+ /**
+ * Create [Tool] instances for agent runtimes (non-MCP).
+ */
+ @JvmStatic
+ fun asTools(tools: DiceMcpTools): List = Tool.fromInstance(tools)
+
+ /**
+ * Create [Tool] instances including entity-resolution helpers when configured.
+ */
+ @JvmStatic
+ fun asToolsWithEntityResolution(
+ tools: DiceMcpTools,
+ entityResolutionService: EntityResolutionService,
+ ): List = asTools(tools) + EntityResolutionTools.asTools(entityResolutionService)
+ }
+}
diff --git a/dice/src/test/kotlin/com/embabel/dice/mcp/DiceMcpToolsTest.kt b/dice/src/test/kotlin/com/embabel/dice/mcp/DiceMcpToolsTest.kt
new file mode 100644
index 00000000..2e3fb563
--- /dev/null
+++ b/dice/src/test/kotlin/com/embabel/dice/mcp/DiceMcpToolsTest.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.mcp
+
+import com.embabel.agent.core.ContextId
+import com.embabel.dice.entity.EntityAssertion
+import com.embabel.dice.proposition.Proposition
+import com.embabel.dice.proposition.PropositionRepository
+import com.embabel.dice.proposition.PropositionStatus
+import com.embabel.dice.web.rest.TestPropositionRepository
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class DiceMcpToolsTest {
+
+ private lateinit var repository: PropositionRepository
+ private lateinit var tools: DiceMcpTools
+
+ @BeforeEach
+ fun setUp() {
+ repository = TestPropositionRepository()
+ tools = DiceMcpTools(repository, minConfidence = 0.0)
+ }
+
+ @Nested
+ inner class StoreAndGetTests {
+
+ @Test
+ fun `store and get round trip`() {
+ val stored = tools.storeMemory("session-1", "User likes jazz", confidence = 0.9)
+ assertTrue(stored.startsWith("Stored proposition"))
+
+ val listed = tools.listMemories("session-1", limit = 5)
+ assertTrue(listed.contains("User likes jazz"))
+
+ val id = stored.substringAfter("Stored proposition ").substringBefore(":")
+ val fetched = tools.getProposition("session-1", id)
+ assertTrue(fetched.contains("User likes jazz"))
+ }
+
+ @Test
+ fun `get rejects wrong context`() {
+ val proposition = repository.save(
+ Proposition(
+ contextId = ContextId("other"),
+ text = "Secret fact",
+ mentions = emptyList(),
+ confidence = 0.8,
+ ),
+ )
+ val result = tools.getProposition("session-1", proposition.id)
+ assertTrue(result.contains("not in context"))
+ }
+ }
+
+ @Nested
+ inner class RecallTests {
+
+ @Test
+ fun `recall finds keyword match`() {
+ repository.save(
+ Proposition(
+ contextId = ContextId("session-1"),
+ text = "Alice works at Acme Corp",
+ mentions = emptyList(),
+ confidence = 0.9,
+ status = PropositionStatus.ACTIVE,
+ ),
+ )
+
+ val result = tools.recall("session-1", query = "Acme", limit = 5)
+ assertTrue(result.contains("Acme"))
+ }
+
+ @Test
+ fun `recall without query lists memories`() {
+ repository.save(
+ Proposition(
+ contextId = ContextId("session-1"),
+ text = "First fact",
+ mentions = emptyList(),
+ confidence = 0.8,
+ ),
+ )
+ repository.save(
+ Proposition(
+ contextId = ContextId("session-1"),
+ text = "Second fact",
+ mentions = emptyList(),
+ confidence = 0.7,
+ ),
+ )
+
+ val result = tools.recall("session-1", query = null, limit = 10)
+ assertTrue(result.contains("First fact"))
+ assertTrue(result.contains("Second fact"))
+ }
+ }
+
+ @Nested
+ inner class ContextIsolationTests {
+
+ @Test
+ fun `list does not leak across contexts`() {
+ repository.save(
+ Proposition(
+ contextId = ContextId("tenant-a"),
+ text = "Tenant A secret",
+ mentions = emptyList(),
+ confidence = 0.9,
+ ),
+ )
+ repository.save(
+ Proposition(
+ contextId = ContextId("tenant-b"),
+ text = "Tenant B fact",
+ mentions = emptyList(),
+ confidence = 0.9,
+ ),
+ )
+
+ val listA = tools.listMemories("tenant-a", limit = 10)
+ assertTrue(listA.contains("Tenant A secret"))
+ assertTrue(!listA.contains("Tenant B fact"))
+
+ val listB = tools.listMemories("tenant-b", limit = 10)
+ assertTrue(listB.contains("Tenant B fact"))
+ assertTrue(!listB.contains("Tenant A secret"))
+ }
+
+ @Test
+ fun `get rejects proposition from another context`() {
+ val proposition = repository.save(
+ Proposition(
+ contextId = ContextId("tenant-a"),
+ text = "Scoped fact",
+ mentions = emptyList(),
+ confidence = 0.9,
+ ),
+ )
+ val result = tools.getProposition("tenant-b", proposition.id)
+ assertTrue(result.contains("not in context"))
+ }
+ }
+
+ @Nested
+ inner class ProfileTests {
+
+ @Test
+ fun `core profile exposes four tools`() {
+ val names = DiceMcpProfile.CORE.toolNames()
+ assertEquals(
+ setOf(
+ DiceMcpTools.RECALL,
+ DiceMcpTools.LIST,
+ DiceMcpTools.STORE,
+ DiceMcpTools.GET,
+ ),
+ names,
+ )
+ }
+
+ @Test
+ fun `extended profile adds optional tools when wired`() {
+ val without = DiceMcpProfile.EXTENDED.toolNames(
+ pipelineAvailable = false,
+ entityResolutionAvailable = false,
+ )
+ assertEquals(4, without.size)
+
+ val withAll = DiceMcpProfile.EXTENDED.toolNames(
+ pipelineAvailable = true,
+ entityResolutionAvailable = true,
+ )
+ assertTrue(withAll.contains(DiceMcpTools.EXTRACT))
+ assertTrue(withAll.contains(DiceMcpTools.ASSERT_ENTITIES))
+ }
+ }
+
+ @Nested
+ inner class OptionalToolTests {
+
+ @Test
+ fun `extract fails fast without pipeline`() {
+ assertThrows {
+ tools.extract("session-1", "Some text about Brahms")
+ }
+ }
+
+ @Test
+ fun `assert entities fails fast without service`() {
+ assertThrows {
+ tools.assertEntities(listOf(EntityAssertion(name = "Alice", labels = listOf("Person"))))
+ }
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 2f9b0237..03498f1e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,7 @@
dice-report
dice-ingestion
dice-integration-tests
+ dice-mcp-autoconfigure
@@ -80,6 +81,11 @@
dice-report
${project.version}
+
+ com.embabel.dice
+ dice-mcp-autoconfigure
+ ${project.version}
+