From 2b40686ae22deb2b30c068c9033c6b183d090338 Mon Sep 17 00:00:00 2001 From: LordKay-sudo Date: Sat, 27 Jun 2026 15:46:35 +0200 Subject: [PATCH] feat(mcp): expose DICE tools for MCP server integration (fixes #5) Add DiceMcpTools with context-scoped parameters for external MCP clients, a core/extended profile split, and dice-mcp-autoconfigure to export tools via embabel-agent McpToolExport when embabel.dice.mcp.enabled=true. Signed-off-by: LordKay-sudo --- AGENTS.md | 5 +- README.md | 50 ++++ dice-mcp-autoconfigure/pom.xml | 81 +++++ .../autoconfigure/DiceMcpAutoConfiguration.kt | 111 +++++++ .../mcp/autoconfigure/DiceMcpProperties.kt | 42 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + dice/AGENTS.md | 1 + .../com/embabel/dice/mcp/DiceMcpProfile.kt | 69 +++++ .../com/embabel/dice/mcp/DiceMcpSupport.kt | 75 +++++ .../com/embabel/dice/mcp/DiceMcpTools.kt | 280 ++++++++++++++++++ .../com/embabel/dice/mcp/DiceMcpToolsTest.kt | 214 +++++++++++++ pom.xml | 6 + 12 files changed, 934 insertions(+), 1 deletion(-) create mode 100644 dice-mcp-autoconfigure/pom.xml create mode 100644 dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpAutoConfiguration.kt create mode 100644 dice-mcp-autoconfigure/src/main/kotlin/com/embabel/dice/mcp/autoconfigure/DiceMcpProperties.kt create mode 100644 dice-mcp-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpProfile.kt create mode 100644 dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpSupport.kt create mode 100644 dice/src/main/kotlin/com/embabel/dice/mcp/DiceMcpTools.kt create mode 100644 dice/src/test/kotlin/com/embabel/dice/mcp/DiceMcpToolsTest.kt 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} +