diff --git a/bt_servant_engine/__init__.py b/bt_servant_engine/__init__.py
index a9a988e..1569ea8 100644
--- a/bt_servant_engine/__init__.py
+++ b/bt_servant_engine/__init__.py
@@ -1,6 +1,6 @@
"""Core package placeholder for upcoming onion architecture refactor."""
-BT_SERVANT_VERSION = "1.3.7"
+BT_SERVANT_VERSION = "1.3.8"
BT_SERVANT_RELEASES_URL = "https://github.com/unfoldingWord/bt-servant-engine/releases"
__all__ = ["BT_SERVANT_VERSION", "BT_SERVANT_RELEASES_URL"]
diff --git a/bt_servant_engine/adapters/user_state.py b/bt_servant_engine/adapters/user_state.py
index 07253b3..be27179 100644
--- a/bt_servant_engine/adapters/user_state.py
+++ b/bt_servant_engine/adapters/user_state.py
@@ -193,6 +193,30 @@ def set_user_agentic_strength(user_id: str, strength: str) -> None:
db.upsert(updated, cond)
+def get_user_dev_agentic_mcp(user_id: str) -> Optional[bool]:
+ """Return whether the user enabled the dev MCP agentic mode."""
+ q = Query()
+ cond = cast(QueryLike, q.user_id == user_id)
+ raw = get_user_db().table("users").get(cond)
+ result = cast(Optional[Dict[str, Any]], raw)
+ value = result.get("dev_agentic_mcp") if result else None
+ return bool(value) if value is not None else None
+
+
+def set_user_dev_agentic_mcp(user_id: str, enabled: bool) -> None:
+ """Persist the user's preference for the dev MCP agentic mode."""
+ q = Query()
+ db = get_user_db().table("users")
+ cond = cast(QueryLike, q.user_id == user_id)
+ existing_raw = db.get(cond)
+ existing = cast(Optional[Dict[str, Any]], existing_raw)
+ updated: Dict[str, Any] = (
+ existing.copy() if isinstance(existing, dict) else {"user_id": user_id}
+ )
+ updated["dev_agentic_mcp"] = bool(enabled)
+ db.upsert(updated, cond)
+
+
def set_first_interaction(user_id: str, is_first: bool) -> None:
"""Set whether this is the user's first interaction."""
q = Query()
@@ -254,6 +278,12 @@ def get_agentic_strength(self, user_id: str) -> str | None:
def set_agentic_strength(self, user_id: str, strength: str) -> None:
set_user_agentic_strength(user_id, strength)
+ def get_dev_agentic_mcp(self, user_id: str) -> bool | None:
+ return get_user_dev_agentic_mcp(user_id)
+
+ def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None:
+ set_user_dev_agentic_mcp(user_id, enabled)
+
def set_first_interaction(self, user_id: str, is_first: bool) -> None:
set_first_interaction(user_id, is_first)
@@ -274,6 +304,8 @@ def is_first_interaction(self, user_id: str) -> bool:
"set_user_last_response_language",
"get_user_agentic_strength",
"set_user_agentic_strength",
+ "get_user_dev_agentic_mcp",
+ "set_user_dev_agentic_mcp",
"set_first_interaction",
"is_first_interaction",
]
diff --git a/bt_servant_engine/apps/api/routes/webhooks.py b/bt_servant_engine/apps/api/routes/webhooks.py
index 9dad029..ca659f5 100644
--- a/bt_servant_engine/apps/api/routes/webhooks.py
+++ b/bt_servant_engine/apps/api/routes/webhooks.py
@@ -93,6 +93,16 @@ def _compute_agentic_strengths(
return effective, user_strength
+def _compute_dev_agentic_mcp(
+ user_id: str, user_state: UserStatePort
+) -> tuple[bool, Optional[bool]]:
+ """Return effective dev MCP flag and stored user preference (if any)."""
+ user_pref = user_state.get_dev_agentic_mcp(user_id=user_id)
+ system_pref = bool(getattr(config, "BT_DEV_AGENTIC_MCP", False))
+ effective = user_pref if user_pref is not None else system_pref
+ return effective, user_pref
+
+
@router.get("/meta-whatsapp")
async def verify_webhook(request: Request):
"""Meta webhook verification endpoint following the standard handshake."""
@@ -480,6 +490,10 @@ async def _invoke_brain(
context.user_message.user_id,
context.user_state,
)
+ effective_dev_agentic_mcp, user_dev_agentic_mcp = _compute_dev_agentic_mcp(
+ context.user_message.user_id,
+ context.user_state,
+ )
brain_payload: dict[str, Any] = {
"user_id": context.user_message.user_id,
"user_query": user_query,
@@ -490,6 +504,7 @@ async def _invoke_brain(
user_id=context.user_message.user_id
),
"agentic_strength": effective_agentic_strength,
+ "dev_agentic_mcp": effective_dev_agentic_mcp,
"perf_trace_id": context.user_message.message_id,
"progress_enabled": config.PROGRESS_MESSAGES_ENABLED,
"progress_messenger": progress_sender,
@@ -498,6 +513,8 @@ async def _invoke_brain(
}
if user_agentic_strength is not None:
brain_payload["user_agentic_strength"] = user_agentic_strength
+ if user_dev_agentic_mcp is not None:
+ brain_payload["user_dev_agentic_mcp"] = user_dev_agentic_mcp
loop = asyncio.get_event_loop()
ctx = copy_context()
diff --git a/bt_servant_engine/core/config.py b/bt_servant_engine/core/config.py
index d3ab7fb..f19cb00 100644
--- a/bt_servant_engine/core/config.py
+++ b/bt_servant_engine/core/config.py
@@ -76,6 +76,7 @@ class Settings(BaseSettings):
CACHE_RAG_FINAL_MAX_ENTRIES: int = Field(default=1500)
DATA_DIR: Path = Field(default=Path("/data"))
+ BT_DEV_AGENTIC_MCP: bool = Field(default=False)
OPENAI_PRICING_JSON: str = Field(
default=(
"{"
diff --git a/bt_servant_engine/core/intents.py b/bt_servant_engine/core/intents.py
index bd127a2..05268b3 100644
--- a/bt_servant_engine/core/intents.py
+++ b/bt_servant_engine/core/intents.py
@@ -22,6 +22,8 @@ class IntentType(str, Enum):
SET_RESPONSE_LANGUAGE = "set-response-language"
CLEAR_RESPONSE_LANGUAGE = "clear-response-language"
SET_AGENTIC_STRENGTH = "set-agentic-strength"
+ SET_DEV_AGENTIC_MCP = "set-dev-agentic-mcp"
+ CLEAR_DEV_AGENTIC_MCP = "clear-dev-agentic-mcp"
CONVERSE_WITH_BT_SERVANT = "converse-with-bt-servant"
diff --git a/bt_servant_engine/core/ports.py b/bt_servant_engine/core/ports.py
index c628aa8..6ce8033 100644
--- a/bt_servant_engine/core/ports.py
+++ b/bt_servant_engine/core/ports.py
@@ -110,6 +110,14 @@ def set_agentic_strength(self, user_id: str, strength: str) -> None:
"""Persist the agentic strength preference."""
...
+ def get_dev_agentic_mcp(self, user_id: str) -> bool | None:
+ """Return whether the dev MCP agentic mode is enabled for the user."""
+ ...
+
+ def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None:
+ """Persist the dev MCP agentic mode preference."""
+ ...
+
def set_first_interaction(self, user_id: str, is_first: bool) -> None:
"""Mark whether ``user_id`` is on their first interaction."""
...
diff --git a/bt_servant_engine/services/brain_nodes.py b/bt_servant_engine/services/brain_nodes.py
index 4e51bb4..6c374e7 100644
--- a/bt_servant_engine/services/brain_nodes.py
+++ b/bt_servant_engine/services/brain_nodes.py
@@ -4,6 +4,7 @@
brain decision graph. These are thin wrappers that delegate to service modules
while handling state extraction and dependency injection.
"""
+# pylint: disable=too-many-lines
from __future__ import annotations
@@ -14,6 +15,7 @@
from openai import OpenAI
from bt_servant_engine.core.config import config
+from bt_servant_engine.core.intents import IntentType
from bt_servant_engine.core.logging import get_logger
from bt_servant_engine.core.ports import ChromaPort, UserStatePort
from bt_servant_engine.services.openai_utils import (
@@ -41,6 +43,7 @@
model_for_agentic_strength as _model_for_agentic_strength,
preprocess_user_query as _preprocess_user_query_impl,
resolve_agentic_strength as _resolve_agentic_strength,
+ resolve_dev_agentic_mcp as _resolve_dev_agentic_mcp,
generate_continuation_actions as _generate_continuation_actions_impl,
)
from bt_servant_engine.services.passage_selection import (
@@ -59,11 +62,15 @@
AgenticStrengthRequest,
ClearResponseLanguageDependencies,
ClearResponseLanguageRequest,
+ DevAgenticMCPDependencies,
+ DevAgenticMCPRequest,
ResponseLanguageDependencies,
ResponseLanguageRequest,
clear_response_language as clear_response_language_impl,
set_agentic_strength as set_agentic_strength_impl,
+ set_dev_agentic_mcp as set_dev_agentic_mcp_impl,
set_response_language as set_response_language_impl,
+ clear_dev_agentic_mcp as clear_dev_agentic_mcp_impl,
)
from bt_servant_engine.services.intents.passage_intents import (
ListenToScriptureRequest,
@@ -119,6 +126,10 @@
build_translation_helps_messages as build_translation_helps_messages_impl,
prepare_translation_helps as prepare_translation_helps_impl,
)
+from bt_servant_engine.services.mcp_agentic import (
+ MCPAgenticDependencies,
+ run_agentic_mcp,
+)
from bt_servant_engine.services.status_messages import get_effective_response_language
from bt_servant_engine.services import runtime
from utils.bsb import BOOK_MAP as BSB_BOOK_MAP
@@ -444,6 +455,30 @@ def set_agentic_strength(state: Any) -> dict:
return set_agentic_strength_impl(request, dependencies)
+def set_dev_agentic_mcp(state: Any) -> dict:
+ """Enable the developer MCP agentic mode for the user."""
+
+ s = _brain_state(state)
+ user_state = _user_state_port()
+ request = DevAgenticMCPRequest(user_id=s["user_id"])
+ dependencies = DevAgenticMCPDependencies(
+ set_user_dev_agentic_mcp=user_state.set_dev_agentic_mcp
+ )
+ return set_dev_agentic_mcp_impl(request, dependencies)
+
+
+def clear_dev_agentic_mcp(state: Any) -> dict:
+ """Disable the developer MCP agentic mode for the user."""
+
+ s = _brain_state(state)
+ user_state = _user_state_port()
+ request = DevAgenticMCPRequest(user_id=s["user_id"])
+ dependencies = DevAgenticMCPDependencies(
+ set_user_dev_agentic_mcp=user_state.set_dev_agentic_mcp
+ )
+ return clear_dev_agentic_mcp_impl(request, dependencies)
+
+
def _collect_truncation_notices(
protected_items: Iterable[dict[str, Any]],
normal_items: Iterable[dict[str, Any]],
@@ -779,6 +814,22 @@ def handle_get_passage_summary(state: Any) -> dict:
s = _brain_state(state)
intent_query = _intent_query_for_node(state, "handle_get_passage_summary_node")
+ dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s))
+ if dev_agentic_mcp:
+ agentic_deps = MCPAgenticDependencies(
+ openai_client=open_ai_client,
+ extract_cached_tokens_fn=_extract_cached_input_tokens,
+ )
+ logger.info("[agentic-mcp] using dev MCP flow for get-passage-summary")
+ response_text = run_agentic_mcp(
+ agentic_deps,
+ user_message=intent_query,
+ intent=IntentType.GET_PASSAGE_SUMMARY,
+ )
+ return {
+ "responses": [{"intent": IntentType.GET_PASSAGE_SUMMARY, "response": response_text}]
+ }
+
agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s))
selection_request = PassageSelectionRequest(
query=intent_query,
@@ -800,6 +851,22 @@ def handle_get_passage_keywords(state: Any) -> dict:
s = _brain_state(state)
intent_query = _intent_query_for_node(state, "handle_get_passage_keywords_node")
+ dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s))
+ if dev_agentic_mcp:
+ agentic_deps = MCPAgenticDependencies(
+ openai_client=open_ai_client,
+ extract_cached_tokens_fn=_extract_cached_input_tokens,
+ )
+ logger.info("[agentic-mcp] using dev MCP flow for get-passage-keywords")
+ response_text = run_agentic_mcp(
+ agentic_deps,
+ user_message=intent_query,
+ intent=IntentType.GET_PASSAGE_KEYWORDS,
+ )
+ return {
+ "responses": [{"intent": IntentType.GET_PASSAGE_KEYWORDS, "response": response_text}]
+ }
+
selection_request = PassageSelectionRequest(
query=intent_query,
query_lang=s["query_language"],
@@ -815,6 +882,23 @@ def handle_get_translation_helps(state: Any) -> dict:
s = _brain_state(state)
intent_query = _intent_query_for_node(state, "handle_get_translation_helps_node")
agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s))
+ dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s))
+
+ if dev_agentic_mcp:
+ agentic_deps = MCPAgenticDependencies(
+ openai_client=open_ai_client,
+ extract_cached_tokens_fn=_extract_cached_input_tokens,
+ )
+ logger.info("[agentic-mcp] using dev MCP flow for get-translation-helps")
+ response_text = run_agentic_mcp(
+ agentic_deps,
+ user_message=intent_query,
+ intent=IntentType.GET_TRANSLATION_HELPS,
+ )
+ return {
+ "responses": [{"intent": IntentType.GET_TRANSLATION_HELPS, "response": response_text}]
+ }
+
request = TranslationHelpsRequestParams(
client=open_ai_client,
query=intent_query,
@@ -839,6 +923,20 @@ def handle_retrieve_scripture(state: Any) -> dict:
s = _brain_state(state)
intent_query = _intent_query_for_node(state, "handle_retrieve_scripture_node")
+ dev_agentic_mcp = _resolve_dev_agentic_mcp(cast(dict[str, Any], s))
+ if dev_agentic_mcp:
+ agentic_deps = MCPAgenticDependencies(
+ openai_client=open_ai_client,
+ extract_cached_tokens_fn=_extract_cached_input_tokens,
+ )
+ logger.info("[agentic-mcp] using dev MCP flow for retrieve-scripture")
+ response_text = run_agentic_mcp(
+ agentic_deps,
+ user_message=intent_query,
+ intent=IntentType.RETRIEVE_SCRIPTURE,
+ )
+ return {"responses": [{"intent": IntentType.RETRIEVE_SCRIPTURE, "response": response_text}]}
+
agentic_strength = _resolve_agentic_strength(cast(dict[str, Any], s))
selection_request = PassageSelectionRequest(
query=intent_query,
@@ -918,6 +1016,8 @@ def _sample_for_language_detection(text: str) -> str:
"determine_intents",
"set_response_language",
"set_agentic_strength",
+ "set_dev_agentic_mcp",
+ "clear_dev_agentic_mcp",
"translate_responses",
"translate_text",
"determine_query_language",
diff --git a/bt_servant_engine/services/brain_orchestrator.py b/bt_servant_engine/services/brain_orchestrator.py
index d34ba31..de3e41d 100644
--- a/bt_servant_engine/services/brain_orchestrator.py
+++ b/bt_servant_engine/services/brain_orchestrator.py
@@ -38,6 +38,8 @@
IntentType.CLEAR_RESPONSE_LANGUAGE: 101,
IntentType.SET_RESPONSE_LANGUAGE: 100,
IntentType.SET_AGENTIC_STRENGTH: 99,
+ IntentType.SET_DEV_AGENTIC_MCP: 98,
+ IntentType.CLEAR_DEV_AGENTIC_MCP: 97,
# Scripture retrieval: Get the text before analyzing it
IntentType.RETRIEVE_SCRIPTURE: 80,
IntentType.LISTEN_TO_SCRIPTURE: 79, # Audio variant of retrieval
@@ -68,6 +70,8 @@
IntentType.SET_RESPONSE_LANGUAGE: "set_response_language_node",
IntentType.CLEAR_RESPONSE_LANGUAGE: "clear_response_language_node",
IntentType.SET_AGENTIC_STRENGTH: "set_agentic_strength_node",
+ IntentType.SET_DEV_AGENTIC_MCP: "set_dev_agentic_mcp_node",
+ IntentType.CLEAR_DEV_AGENTIC_MCP: "clear_dev_agentic_mcp_node",
IntentType.PERFORM_UNSUPPORTED_FUNCTION: "handle_unsupported_function_node",
IntentType.RETRIEVE_SYSTEM_INFORMATION: "handle_system_information_request_node",
IntentType.CONVERSE_WITH_BT_SERVANT: "converse_with_bt_servant_node",
@@ -291,6 +295,8 @@ class BrainState(TypedDict, total=False):
user_response_language: str
agentic_strength: str
user_agentic_strength: str
+ dev_agentic_mcp: bool
+ user_dev_agentic_mcp: bool
transformed_query: str
docs: List[Dict[str, Any]]
collection_used: str
@@ -549,7 +555,7 @@ def process_intents(state: Any) -> List[Hashable]:
return cast(List[Hashable], sends)
-def create_brain():
+def create_brain(): # noqa: PLR0915
"""Assemble and compile the LangGraph for the BT Servant brain."""
def _make_state_graph(schema: Any) -> StateGraph:
@@ -596,6 +602,14 @@ def _should_show_translation_progress(state: Any) -> bool:
"set_agentic_strength_node",
wrap_node_with_timing(brain_nodes.set_agentic_strength, "set_agentic_strength_node"),
)
+ builder.add_node(
+ "set_dev_agentic_mcp_node",
+ wrap_node_with_timing(brain_nodes.set_dev_agentic_mcp, "set_dev_agentic_mcp_node"),
+ )
+ builder.add_node(
+ "clear_dev_agentic_mcp_node",
+ wrap_node_with_timing(brain_nodes.clear_dev_agentic_mcp, "clear_dev_agentic_mcp_node"),
+ )
builder.add_node(
"query_vector_db_node",
wrap_node_with_timing(
@@ -746,6 +760,8 @@ def _should_show_translation_progress(state: Any) -> bool:
builder.add_edge("set_response_language_node", "translate_responses_node")
builder.add_edge("clear_response_language_node", "translate_responses_node")
builder.add_edge("set_agentic_strength_node", "translate_responses_node")
+ builder.add_edge("set_dev_agentic_mcp_node", "translate_responses_node")
+ builder.add_edge("clear_dev_agentic_mcp_node", "translate_responses_node")
# After chunking, finish. Do not loop back to translate, which can recreate
# the long message and trigger an infinite chunk cycle.
diff --git a/bt_servant_engine/services/intents/settings_intents.py b/bt_servant_engine/services/intents/settings_intents.py
index a3040b6..9eddb27 100644
--- a/bt_servant_engine/services/intents/settings_intents.py
+++ b/bt_servant_engine/services/intents/settings_intents.py
@@ -227,6 +227,50 @@ def set_agentic_strength(
}
+@dataclass(slots=True)
+class DevAgenticMCPRequest:
+ """Inputs required to toggle the dev MCP agentic mode."""
+
+ user_id: str
+
+
+@dataclass(slots=True)
+class DevAgenticMCPDependencies:
+ """Persistence callbacks for dev MCP agentic mode."""
+
+ set_user_dev_agentic_mcp: Callable[[str, bool], Any]
+
+
+def set_dev_agentic_mcp(
+ request: DevAgenticMCPRequest,
+ dependencies: DevAgenticMCPDependencies,
+) -> dict[str, Any]:
+ """Enable the agentic MCP flow for the current user."""
+
+ dependencies.set_user_dev_agentic_mcp(request.user_id, True)
+ response_text = "Developer MCP mode enabled for translation helps."
+ return {
+ "responses": [{"intent": IntentType.SET_DEV_AGENTIC_MCP, "response": response_text}],
+ "dev_agentic_mcp": True,
+ "user_dev_agentic_mcp": True,
+ }
+
+
+def clear_dev_agentic_mcp(
+ request: DevAgenticMCPRequest,
+ dependencies: DevAgenticMCPDependencies,
+) -> dict[str, Any]:
+ """Disable the agentic MCP flow for the current user."""
+
+ dependencies.set_user_dev_agentic_mcp(request.user_id, False)
+ response_text = "Developer MCP mode disabled. Using the standard flow."
+ return {
+ "responses": [{"intent": IntentType.CLEAR_DEV_AGENTIC_MCP, "response": response_text}],
+ "dev_agentic_mcp": False,
+ "user_dev_agentic_mcp": False,
+ }
+
+
@dataclass(slots=True)
class ClearResponseLanguageRequest:
"""Inputs required to clear the stored response language."""
@@ -266,7 +310,11 @@ def clear_response_language(
"ClearResponseLanguageDependencies",
"AgenticStrengthRequest",
"AgenticStrengthDependencies",
+ "DevAgenticMCPRequest",
+ "DevAgenticMCPDependencies",
"set_response_language",
"set_agentic_strength",
+ "set_dev_agentic_mcp",
+ "clear_dev_agentic_mcp",
"clear_response_language",
]
diff --git a/bt_servant_engine/services/mcp_agentic.py b/bt_servant_engine/services/mcp_agentic.py
new file mode 100644
index 0000000..003eb31
--- /dev/null
+++ b/bt_servant_engine/services/mcp_agentic.py
@@ -0,0 +1,360 @@
+"""Agentic MCP helper for Translation Helps.
+
+This module provides a lightweight planner/executor/finalizer flow that lets
+the LLM pick which Translation Helps MCP tools/prompts to call, validate the
+arguments, execute them, and compose a final response.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, cast
+
+from openai import OpenAI
+from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam
+from openai.types.responses.easy_input_message_param import EasyInputMessageParam
+from pydantic import BaseModel, Field
+from translation_helps import TranslationHelpsClient # type: ignore # pylint: disable=import-error
+
+from bt_servant_engine.core.intents import IntentType
+from bt_servant_engine.core.logging import get_logger
+from bt_servant_engine.services.openai_utils import track_openai_usage
+from utils.perf import add_tokens
+
+logger = get_logger(__name__)
+
+ALLOWED_AGENTIC_TOOLS: set[str] = {
+ "prompt_translation-helps-for-passage",
+ "prompt_get-translation-words-for-passage",
+ "prompt_get-translation-academy-for-passage",
+ "fetch_translation_notes",
+ "fetch_translation_word_links",
+ "fetch_translation_word",
+ "fetch_translation_academy",
+ "fetch_translation_questions",
+ "fetch_scripture",
+}
+
+PLAN_SYSTEM_PROMPT = """
+You select the best MCP tools/prompts to answer the user's request.
+
+Rules:
+- Choose 1-3 calls from the allowed list.
+- Use the required parameters shown in the manifest.
+- Do not guess parameters; prefer the user's wording and references.
+- Return ONLY a JSON object matching the schema.
+"""
+
+FINALIZER_SYSTEM_PROMPT = """
+You are composing a reply using ONLY the provided MCP tool results.
+- Be concise and helpful.
+- If scripture text appears, keep it verbatim.
+- If the intent is get-passage-summary, produce a concise summary (3–5 sentences max).
+- If nothing useful is present, say so briefly.
+- Offer 2 short follow-up questions that relate to the data retrieved.
+"""
+
+FAILURE_MESSAGE_EN = (
+ "Sorry. I couldn't figure out what to do. Please pass this along to my creators."
+)
+
+
+class ToolCall(BaseModel):
+ """Single MCP invocation request."""
+
+ name: str
+ args: Dict[str, Any] = Field(default_factory=dict)
+
+
+class Plan(BaseModel):
+ """Structured plan produced by the planner LLM."""
+
+ calls: List[ToolCall] = Field(default_factory=list)
+ rationale: Optional[str] = None
+
+
+@dataclass(slots=True)
+class MCPToolSpec:
+ """Metadata for validating tool calls."""
+
+ name: str
+ required: List[str]
+ is_prompt: bool = False
+
+
+@dataclass(slots=True)
+class MCPAgenticDependencies:
+ """External helpers needed by the agentic MCP flow."""
+
+ openai_client: OpenAI
+ extract_cached_tokens_fn: Callable[..., Any]
+
+
+async def _load_manifest(client) -> Dict[str, MCPToolSpec]:
+ """Load tools/prompts and reduce to the allowed manifest."""
+ specs: Dict[str, MCPToolSpec] = {}
+ tools = await client.list_tools()
+ for tool in tools:
+ name = str(tool.get("name", "")).strip()
+ if name not in ALLOWED_AGENTIC_TOOLS:
+ continue
+ schema = tool.get("inputSchema") or {}
+ required = schema.get("required") or []
+ required_list = [str(item) for item in required if isinstance(item, str)]
+ specs[name] = MCPToolSpec(name=name, required=required_list, is_prompt=False)
+
+ prompts = await client.list_prompts()
+ for prompt in prompts:
+ name = str(prompt.get("name", "")).strip()
+ full_name = f"prompt_{name}" if name else ""
+ if full_name not in ALLOWED_AGENTIC_TOOLS:
+ continue
+ args = prompt.get("arguments") or []
+ required_args: list[str] = []
+ for arg in args:
+ if isinstance(arg, Mapping) and arg.get("required"):
+ arg_name = arg.get("name")
+ if isinstance(arg_name, str):
+ required_args.append(arg_name)
+ specs[full_name] = MCPToolSpec(name=full_name, required=required_args, is_prompt=True)
+ return specs
+
+
+def _manifest_summary(specs: Mapping[str, MCPToolSpec]) -> str:
+ """Return a compact JSON manifest for the planner prompt."""
+ summary = [
+ {
+ "name": spec.name,
+ "required": spec.required,
+ "is_prompt": spec.is_prompt,
+ }
+ for spec in specs.values()
+ ]
+ return json.dumps(summary, ensure_ascii=False)
+
+
+def _plan_calls(
+ deps: MCPAgenticDependencies,
+ user_message: str,
+ intent: IntentType,
+ manifest_summary: str,
+ prior_error: str | None = None,
+) -> Plan:
+ """Ask the LLM to select MCP calls."""
+
+ messages: list[EasyInputMessageParam] = [
+ {
+ "role": "user",
+ "content": (
+ f"User request: {user_message}\n"
+ f"Intent: {intent.value}\n"
+ f"Allowed tools: {manifest_summary}\n"
+ "Return a JSON object with calls and optional rationale."
+ ),
+ }
+ ]
+ if prior_error:
+ messages.append(
+ {
+ "role": "user",
+ "content": f"The previous plan failed validation: {prior_error}",
+ }
+ )
+
+ resp = deps.openai_client.responses.parse(
+ model="gpt-4o",
+ instructions=PLAN_SYSTEM_PROMPT,
+ input=cast(Any, messages),
+ text_format=Plan,
+ temperature=0,
+ store=False,
+ )
+ usage = getattr(resp, "usage", None)
+ track_openai_usage(usage, "gpt-4o", deps.extract_cached_tokens_fn, add_tokens)
+ parsed = resp.output_parsed
+ return parsed if isinstance(parsed, Plan) else Plan(calls=[])
+
+
+def _validate_plan(plan: Plan, specs: Mapping[str, MCPToolSpec]) -> str | None:
+ """Validate the planned calls against the manifest."""
+ for call in plan.calls:
+ spec = specs.get(call.name)
+ if spec is None:
+ return f"unknown tool: {call.name}"
+ if not isinstance(call.args, dict):
+ return f"arguments for {call.name} must be an object"
+ missing = [
+ req for req in spec.required if req not in call.args or call.args[req] in (None, "")
+ ]
+ if missing:
+ return f"{call.name} is missing required fields: {', '.join(missing)}"
+ if not plan.calls:
+ return "no calls were provided"
+ return None
+
+
+async def _execute_prompt(client, name: str, params: Dict[str, Any]) -> str:
+ """Execute a prompt via the REST endpoint."""
+ if not client._http_client: # pylint: disable=protected-access
+ raise RuntimeError("MCP client HTTP session is not initialized.")
+ url = client.server_url.replace("/api/mcp", "/api/execute-prompt")
+ response = await client._http_client.post( # pylint: disable=protected-access
+ url,
+ json={"promptName": name, "parameters": params},
+ )
+ response.raise_for_status()
+ data = response.json()
+ return json.dumps(data, ensure_ascii=False)
+
+
+def _extract_text_from_content(content: Iterable[Any]) -> str:
+ """Extract text blobs from MCP tool content."""
+ chunks: list[str] = []
+ for item in content:
+ if isinstance(item, dict):
+ if "text" in item:
+ chunks.append(str(item.get("text", "")))
+ elif item.get("type") == "text":
+ chunks.append(str(item.get("text", "")))
+ elif isinstance(item, str):
+ chunks.append(item)
+ return "".join(chunks)
+
+
+async def _execute_calls(
+ client,
+ plan: Plan,
+ specs: Mapping[str, MCPToolSpec],
+) -> list[dict[str, Any]]:
+ """Execute planned calls and capture text outputs."""
+ results: list[dict[str, Any]] = []
+ for call in plan.calls:
+ spec = specs[call.name]
+ try:
+ logger.info(
+ "[agentic-mcp] Calling %s (prompt=%s) with args=%s",
+ call.name,
+ spec.is_prompt,
+ sorted(call.args.keys()),
+ )
+ if spec.is_prompt:
+ prompt_name = call.name.replace("prompt_", "", 1)
+ text = await _execute_prompt(client, prompt_name, call.args)
+ else:
+ resp = await client.call_tool(call.name, call.args)
+ content = resp.get("content", []) if isinstance(resp, dict) else []
+ text = _extract_text_from_content(content)
+ if not text and resp.get("text"):
+ text = str(resp["text"])
+ results.append({"name": call.name, "args": call.args, "content": text})
+ logger.info("[agentic-mcp] Completed %s (chars=%d)", call.name, len(text or ""))
+ except Exception as exc: # pylint: disable=broad-except
+ logger.warning("[agentic-mcp] %s failed: %s", call.name, exc)
+ results.append({"name": call.name, "args": call.args, "content": f"[ERROR] {exc}"})
+ return results
+
+
+def _finalize_response(
+ deps: MCPAgenticDependencies,
+ user_message: str,
+ intent: IntentType,
+ tool_results: list[dict[str, Any]],
+) -> str:
+ """Ask the LLM to compose the final reply from tool outputs."""
+ messages: list[ChatCompletionMessageParam] = [
+ {"role": "system", "content": FINALIZER_SYSTEM_PROMPT},
+ {"role": "user", "content": f"User request: {user_message}\nIntent: {intent.value}"},
+ {
+ "role": "assistant",
+ "content": json.dumps(tool_results, ensure_ascii=False),
+ },
+ ]
+ completion = deps.openai_client.chat.completions.create(
+ model="gpt-4o",
+ messages=cast(Any, messages),
+ temperature=0.4,
+ )
+ usage = getattr(completion, "usage", None)
+ track_openai_usage(usage, "gpt-4o", deps.extract_cached_tokens_fn, add_tokens)
+ content = completion.choices[0].message.content
+ if isinstance(content, list):
+ return "".join(part.get("text", "") if isinstance(part, dict) else "" for part in content)
+ return content or ""
+
+
+async def _run_agentic_mcp_async(
+ deps: MCPAgenticDependencies,
+ user_message: str,
+ intent: IntentType,
+ max_plan_attempts: int = 2,
+) -> str:
+ """Async workflow to plan, validate, execute, and finalize MCP calls."""
+ server_url = os.getenv("MCP_SERVER_URL")
+ client_options = {"serverUrl": server_url} if server_url else {}
+ mcp_client = TranslationHelpsClient(client_options)
+ await mcp_client.connect()
+ try:
+ manifest = await _load_manifest(mcp_client)
+ if not manifest:
+ logger.warning("[agentic-mcp] Manifest is empty; aborting.")
+ return FAILURE_MESSAGE_EN
+ logger.info("[agentic-mcp] Loaded manifest with %d tools", len(manifest))
+
+ manifest_summary = _manifest_summary(manifest)
+ validation_error: str | None = None
+ plan: Plan = Plan(calls=[])
+
+ for _ in range(max_plan_attempts):
+ plan = _plan_calls(
+ deps,
+ user_message=user_message,
+ intent=intent,
+ manifest_summary=manifest_summary,
+ prior_error=validation_error,
+ )
+ validation_error = _validate_plan(plan, manifest)
+ if validation_error is None:
+ logger.info(
+ "[agentic-mcp] Plan validated with %d call(s): %s",
+ len(plan.calls),
+ ", ".join(call.name for call in plan.calls),
+ )
+ break
+ if validation_error is not None:
+ logger.info("[agentic-mcp] Plan failed after retries: %s", validation_error)
+ return FAILURE_MESSAGE_EN
+
+ tool_results = await _execute_calls(mcp_client, plan, manifest)
+ logger.info(
+ "[agentic-mcp] Executed %d calls successfully for intent=%s",
+ len(tool_results),
+ intent.value,
+ )
+ final_response = _finalize_response(deps, user_message, intent, tool_results)
+ logger.info(
+ "[agentic-mcp] Final response length=%d chars for intent=%s",
+ len(final_response or ""),
+ intent.value,
+ )
+ return final_response
+ finally:
+ await mcp_client.close()
+
+
+def run_agentic_mcp(
+ deps: MCPAgenticDependencies,
+ user_message: str,
+ intent: IntentType,
+) -> str:
+ """Public entry point for the agentic MCP flow."""
+ try:
+ return asyncio.run(_run_agentic_mcp_async(deps, user_message=user_message, intent=intent))
+ except Exception as exc: # pylint: disable=broad-except
+ logger.error("[agentic-mcp] Unexpected failure: %s", exc, exc_info=True)
+ return FAILURE_MESSAGE_EN
+
+
+__all__ = ["MCPAgenticDependencies", "run_agentic_mcp", "FAILURE_MESSAGE_EN"]
diff --git a/bt_servant_engine/services/preprocessing.py b/bt_servant_engine/services/preprocessing.py
index 23a1bcb..b68a2bd 100644
--- a/bt_servant_engine/services/preprocessing.py
+++ b/bt_servant_engine/services/preprocessing.py
@@ -339,6 +339,14 @@
The user wants to change the agentic strength of the assistant's responses (for example: "Set my agentic strength to
low", "Increase the detail of your answers"). Supported levels: normal, low, very_low.
+
+ The user wants to enable the developer MCP agentic mode for translation helps/key passages (for example:
+ "turn on dev MCP mode", "use the MCP developer mode", "enable the agentic MCP flow").
+
+
+ The user wants to disable the developer MCP agentic mode and return to the standard flow (for example:
+ "turn off dev MCP mode", "stop using the MCP developer mode", "disable agentic MCP").
+
The user wants information about the BT Servant system itself—its resources, capabilities, uptime, data sources, or
other operational details.
@@ -404,6 +412,14 @@
translate the French version of John 1:1 into Spanish
translate-scripture
+
+ enable the MCP developer mode
+ set-dev-agentic-mcp
+
+
+ stop using the MCP dev flow
+ clear-dev-agentic-mcp
+
Please provide the text of Job 1:1-5.
retrieve-scripture
@@ -604,6 +620,18 @@ def resolve_agentic_strength(state: dict[str, Any]) -> str:
return "normal"
+def resolve_dev_agentic_mcp(state: dict[str, Any]) -> bool:
+ """Return whether the dev MCP agentic mode is enabled."""
+
+ for key in ("dev_agentic_mcp", "user_dev_agentic_mcp"):
+ val = state.get(key)
+ if isinstance(val, bool):
+ return val
+
+ configured = getattr(config, "BT_DEV_AGENTIC_MCP", False)
+ return bool(configured)
+
+
def model_for_agentic_strength(
agentic_strength: str,
*,
diff --git a/env.example b/env.example
index f5736c7..668584c 100644
--- a/env.example
+++ b/env.example
@@ -44,3 +44,6 @@ HEALTHCHECK_API_TOKEN=change-me-for-health
# Agentic creativity tuning for LLM calls (normal | low)
AGENTIC_STRENGTH=normal
+
+# Developer toggle for agentic MCP flow (false by default)
+BT_DEV_AGENTIC_MCP=false
diff --git a/pyproject.toml b/pyproject.toml
index 90b43be..cda5bea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bt-servant-engine"
-version = "1.3.7"
+version = "1.3.8"
description = "BT Servant Engine — strict onion/hexagonal refactor baseline"
readme = "README.md"
requires-python = ">=3.12"
@@ -22,6 +22,7 @@ dependencies = [
"tinydb>=4.8",
"typing-extensions>=4.12",
"python-json-logger>=2.0.7",
+ "translation-helps-mcp-client>=1.2.0",
]
[project.optional-dependencies]
@@ -73,6 +74,9 @@ max-args = 5 # PLR0913
[tool.ruff.lint.mccabe]
max-complexity = 10 # C901
+[tool.deptry.package_module_name_map]
+"translation-helps-mcp-client" = "translation_helps"
+
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
diff --git a/requirements.txt b/requirements.txt
index 8f148ed..c5e16a7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -132,6 +132,7 @@ tokenizers==0.21.1
tqdm==4.67.1
twilio==9.5.2
typer==0.15.2
+translation-helps-mcp-client==1.2.0
typing-inspect==0.9.0
typing-inspection==0.4.0
typing_extensions==4.13.2
diff --git a/tests/adapters/test_user_state_adapter.py b/tests/adapters/test_user_state_adapter.py
index b65b028..d14bc7c 100644
--- a/tests/adapters/test_user_state_adapter.py
+++ b/tests/adapters/test_user_state_adapter.py
@@ -81,6 +81,19 @@ def test_agentic_strength_roundtrip(temp_user_db: TinyDB) -> None:
assert user_state.get_user_agentic_strength(user_id) is None
+def test_dev_agentic_mcp_roundtrip(temp_user_db: TinyDB) -> None:
+ """Dev MCP toggle round-trips boolean values."""
+ del temp_user_db
+ user_id = "dev-mcp"
+ assert user_state.get_user_dev_agentic_mcp(user_id) is None
+
+ user_state.set_user_dev_agentic_mcp(user_id, True)
+ assert user_state.get_user_dev_agentic_mcp(user_id) is True
+
+ user_state.set_user_dev_agentic_mcp(user_id, False)
+ assert user_state.get_user_dev_agentic_mcp(user_id) is False
+
+
def test_first_interaction_flags(temp_user_db: TinyDB) -> None:
"""First-interaction flag flips and persists via adapter helpers."""
del temp_user_db
@@ -94,7 +107,10 @@ def test_first_interaction_flags(temp_user_db: TinyDB) -> None:
assert user_state.is_first_interaction(user_id) is True
-def test_user_state_adapter_methods_delegate(monkeypatch: pytest.MonkeyPatch) -> None:
+# pylint: disable=too-many-locals
+def test_user_state_adapter_methods_delegate( # noqa: PLR0915
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
"""Adapter delegates work to the module-level helpers."""
adapter = user_state.UserStateAdapter()
@@ -134,6 +150,13 @@ def fake_get_strength(uid: str) -> str | None:
def fake_set_strength(uid: str, strength: str) -> None:
record(f"set_strength:{uid}:{strength}")
+ def fake_get_dev_mcp(uid: str) -> bool | None:
+ record(f"get_dev:{uid}")
+ return cast(bool | None, None)
+
+ def fake_set_dev_mcp(uid: str, enabled: bool) -> None:
+ record(f"set_dev:{uid}:{enabled}")
+
def fake_set_first(uid: str, val: bool) -> None:
record(f"set_first:{uid}:{val}")
@@ -150,6 +173,8 @@ def fake_is_first(uid: str) -> bool:
monkeypatch.setattr(user_state, "set_user_last_response_language", fake_set_last_lang)
monkeypatch.setattr(user_state, "get_user_agentic_strength", fake_get_strength)
monkeypatch.setattr(user_state, "set_user_agentic_strength", fake_set_strength)
+ monkeypatch.setattr(user_state, "get_user_dev_agentic_mcp", fake_get_dev_mcp)
+ monkeypatch.setattr(user_state, "set_user_dev_agentic_mcp", fake_set_dev_mcp)
monkeypatch.setattr(user_state, "set_first_interaction", fake_set_first)
monkeypatch.setattr(user_state, "is_first_interaction", fake_is_first)
@@ -162,6 +187,8 @@ def fake_is_first(uid: str) -> bool:
adapter.set_last_response_language("u1", "nl")
adapter.get_agentic_strength("u1")
adapter.set_agentic_strength("u1", "normal")
+ adapter.get_dev_agentic_mcp("u1")
+ adapter.set_dev_agentic_mcp("u1", True)
adapter.set_first_interaction("u1", False)
adapter.is_first_interaction("u1")
@@ -175,6 +202,8 @@ def fake_is_first(uid: str) -> bool:
"set_last_lang:u1:nl",
"get_strength:u1",
"set_strength:u1:normal",
+ "get_dev:u1",
+ "set_dev:u1:True",
"set_first:u1:False",
"is_first:u1",
]
diff --git a/tests/conftest.py b/tests/conftest.py
index 9a6833d..66f8f41 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -84,6 +84,14 @@ def set_agentic_strength(self, user_id: str, strength: str) -> None:
raise ValueError(f"Invalid agentic strength: {strength}")
self.save_user_state(user_id, {"agentic_strength": normalized})
+ def get_dev_agentic_mcp(self, user_id: str) -> bool | None:
+ state = self._states.get(user_id, {})
+ value = state.get("dev_agentic_mcp")
+ return bool(value) if value is not None else None
+
+ def set_dev_agentic_mcp(self, user_id: str, enabled: bool) -> None:
+ self.save_user_state(user_id, {"dev_agentic_mcp": bool(enabled)})
+
def set_first_interaction(self, user_id: str, is_first: bool) -> None:
self.save_user_state(user_id, {"first_interaction": is_first})