From 2a8843560e14104b9ca2a347a6bbb443588bdb5d Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 07:08:10 +0200 Subject: [PATCH 1/8] feat: make mcp tool docstring configurable --- .../rag/templates/backend/configmap.yaml | 8 +- infrastructure/rag/values.yaml | 2 + services/mcp-server/src/rag_mcp_server.py | 111 +++++++----------- .../mcp-server/src/settings/mcp_settings.py | 14 +++ 4 files changed, 60 insertions(+), 75 deletions(-) diff --git a/infrastructure/rag/templates/backend/configmap.yaml b/infrastructure/rag/templates/backend/configmap.yaml index 72b5a8f7..4bb0e516 100644 --- a/infrastructure/rag/templates/backend/configmap.yaml +++ b/infrastructure/rag/templates/backend/configmap.yaml @@ -140,7 +140,7 @@ metadata: name: {{ template "configmap.mcp" . }} data: MCP_PORT: {{ .Values.backend.mcp.port | quote }} - MCP_TOOL_NAME: {{ .Values.backend.mcp.toolName }} - MCP_TOOL_DESCRIPTION: {{ .Values.backend.mcp.toolDescription }} - MCP_NAME: {{ .Values.backend.mcp.name }} ---- \ No newline at end of file + MCP_HOST: {{ .Values.backend.mcp.host | quote }} + MCP_NAME: {{ .Values.backend.mcp.name | quote }} + MCP_CHAT_SIMPLE_DESCRIPTION: {{ .Values.backend.mcp.chatSimpleDescription | quote }} + MCP_CHAT_WITH_HISTORY_DESCRIPTION: {{ .Values.backend.mcp.chatWithHistoryDescription | quote }} diff --git a/infrastructure/rag/values.yaml b/infrastructure/rag/values.yaml index 4ae9b608..4d9fdcaf 100644 --- a/infrastructure/rag/values.yaml +++ b/infrastructure/rag/values.yaml @@ -20,6 +20,8 @@ backend: name: "mcp" port: "8000" host: "0.0.0.0" + chatSimpleDescription: "Send a message to the RAG system and get a simple text response. This is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." + chatWithHistoryDescription: "Send a message with conversation history and get structured response. Provide conversation history as a simple list of dictionaries. Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys." image: repository: ghcr.io/stackitcloud/rag-template name: rag-mcp diff --git a/services/mcp-server/src/rag_mcp_server.py b/services/mcp-server/src/rag_mcp_server.py index d7d2a591..0d3e6f67 100644 --- a/services/mcp-server/src/rag_mcp_server.py +++ b/services/mcp-server/src/rag_mcp_server.py @@ -30,80 +30,49 @@ def run(self): logger.info(f"Starting FastMCP Server on {self.TRANSPORT}://{self._settings.host}:{self._settings.port}") self._server.run(transport=self.TRANSPORT, host=self._settings.host, port=self._settings.port) - async def chat_simple(self, session_id: str, message: str) -> str: - """Send a message to the RAG system and get a simple text response. - - This is the simplest way to interact with the RAG system - just provide a message - and get back the answer as plain text. - - Parameters - ---------- - session_id: str - Unique identifier for the chat session. - message: str - The message/question to ask the RAG system. - - Returns - ------- - str - The answer from the RAG system as plain text. - """ - chat_request = ChatRequest(message=message) - response = await self._handle_chat(session_id, chat_request) - return response.answer - - async def chat_with_history( - self, session_id: str, message: str, history: list[dict[str, str]] = None - ) -> dict[str, Any]: - """Send a message with conversation history and get structured response. - - Provide conversation history as a simple list of dictionaries. - Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys. - - Parameters - ---------- - session_id: str - Unique identifier for the chat session. - message: str - The current message/question to ask. - history: List[Dict[str, str]], optional - Previous conversation history. Each item should be: - {"role": "user" or "assistant", "message": "the message text"} - - Returns - ------- - Dict[str, Any] - Response containing: - - answer: The response text - - finish_reason: Why the response ended - - citations: List of source documents used (simplified) - """ - # Build chat history if provided - chat_history = None - if history: - history_messages = [] - for item in history: - role = ChatRole.USER if item.get("role", "").lower() == "user" else ChatRole.ASSISTANT - history_messages.append(ChatHistoryMessage(role=role, message=item["message"])) - chat_history = ChatHistory(messages=history_messages) - - chat_request = ChatRequest(message=message, history=chat_history) - response = await self._handle_chat(session_id, chat_request) - - # Simplify citations for easier consumption - simplified_citations = [] - for citation in response.citations: - simplified_citations.append( - {"content": citation.page_content, "metadata": {pair.key: pair.value for pair in citation.metadata}} - ) - - return {"answer": response.answer, "finish_reason": response.finish_reason, "citations": simplified_citations} - def _register_tools(self): """Register all MCP tools with the server.""" + + async def chat_simple(session_id: str, message: str) -> str: + chat_request = ChatRequest(message=message) + response = await self._handle_chat(session_id, chat_request) + return response.answer + + chat_simple.__doc__ = self._settings.chat_simple_description + + async def chat_with_history( + session_id: str, message: str, history: list[dict[str, str]] = None + ) -> dict[str, Any]: + # Build chat history if provided + chat_history = None + if history: + history_messages = [] + for item in history: + role = ChatRole.USER if item.get("role", "").lower() == "user" else ChatRole.ASSISTANT + history_messages.append(ChatHistoryMessage(role=role, message=item["message"])) + chat_history = ChatHistory(messages=history_messages) + + chat_request = ChatRequest(message=message, history=chat_history) + response = await self._handle_chat(session_id, chat_request) + + # Simplify citations for easier consumption + simplified_citations = [] + for citation in response.citations: + simplified_citations.append( + {"content": citation.page_content, "metadata": {pair.key: pair.value for pair in citation.metadata}} + ) + + return { + "answer": response.answer, + "finish_reason": response.finish_reason, + "citations": simplified_citations, + } + + chat_with_history.__doc__ = self._settings.chat_with_history_description + # Simple tools for basic agent usage - self._server.add_tool(self._server.tool(self.chat_simple)) - self._server.add_tool(self._server.tool(self.chat_with_history)) + self._server.add_tool(self._server.tool(chat_simple)) + self._server.add_tool(self._server.tool(chat_with_history)) async def _handle_chat(self, session_id: str, chat_request: ChatRequest) -> ChatResponse: """Handle the chat request with the RAG backend.""" diff --git a/services/mcp-server/src/settings/mcp_settings.py b/services/mcp-server/src/settings/mcp_settings.py index a4eb1cfa..0b774ec0 100644 --- a/services/mcp-server/src/settings/mcp_settings.py +++ b/services/mcp-server/src/settings/mcp_settings.py @@ -31,3 +31,17 @@ class Config: host: str = Field(default="0.0.0.0") port: int = Field(default=8000) name: str = Field(default="RAG MCP server") + chat_simple_description: str = Field( + default="""Send a message to the RAG system and get a simple text response. + + This is the simplest way to interact with the RAG system - just provide a message + and get back the answer as plain text. + """ + ) + chat_with_history_description: str = Field( + default="""Send a message with conversation history and get structured response. + + Provide conversation history as a simple list of dictionaries. + Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys. + """ + ) From 71e0d0408ef0e53b87716920e410fae7edc698ee Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 11:15:49 +0200 Subject: [PATCH 2/8] feat: enhance MCP server with extensible docstring system and update configurations --- .vscode/settings.json | 7 +- conftest.py | 12 +- .../rag/templates/backend/configmap.yaml | 6 + infrastructure/rag/values.yaml | 22 +- libs/rag-core-api/pyproject.toml | 2 +- services/mcp-server/pyproject.toml | 18 + services/mcp-server/src/__init__.py | 1 + services/mcp-server/src/docstring_system.py | 223 +++++++++++ services/mcp-server/src/rag_mcp_server.py | 80 ++-- .../mcp-server/src/settings/mcp_settings.py | 43 +- .../mcp-server/tests/docstring_system_test.py | 370 ++++++++++++++++++ services/mcp-server/tests/dummy7_test.py | 3 - 12 files changed, 722 insertions(+), 65 deletions(-) create mode 100644 services/mcp-server/src/__init__.py create mode 100644 services/mcp-server/src/docstring_system.py create mode 100644 services/mcp-server/tests/docstring_system_test.py delete mode 100644 services/mcp-server/tests/dummy7_test.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 650fca03..17bbe8d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,9 +6,10 @@ "./libs/rag-core-api/src", "./libs/rag-core-lib/src", "./libs/extractor-api-lib/src", - "./admin-backend", - "./rag-backend", - "./document-extractor" + "./services/mcp-server/src", + "./services/admin-backend", + "./services/rag-backend", + "./services/document-extractor" ], "[yaml]": { "editor.tabSize": 2, diff --git a/conftest.py b/conftest.py index 2cc40bfa..0dcd17f6 100644 --- a/conftest.py +++ b/conftest.py @@ -4,12 +4,16 @@ # Add project root and specific directories to Python path project_root = Path(__file__).parent -sys.path.insert(0, str(project_root)) -sys.path.insert(0, str(project_root / "admin-backend")) -sys.path.insert(0, str(project_root / "rag-backend")) -sys.path.insert(0, str(project_root / "document-extractor")) + +# Add services src directories to Python path +services_root = project_root / "services" +for service in ["admin-backend", "rag-backend", "document-extractor", "mcp-server"]: + service_src = services_root / service / "src" + if service_src.exists(): + sys.path.insert(0, str(service_src)) # point at each rag-core library's src folder so their packages (admin_api_lib, rag_core_api, etc.) are importable lib_root = project_root / "libs" for lib in ["admin-api-lib", "rag-core-api", "rag-core-lib", "extractor-api-lib"]: sys.path.insert(0, str(lib_root / lib / "src")) + sys.path.insert(0, str(lib_root / lib / "tests")) diff --git a/infrastructure/rag/templates/backend/configmap.yaml b/infrastructure/rag/templates/backend/configmap.yaml index 4bb0e516..fa2db76a 100644 --- a/infrastructure/rag/templates/backend/configmap.yaml +++ b/infrastructure/rag/templates/backend/configmap.yaml @@ -143,4 +143,10 @@ data: MCP_HOST: {{ .Values.backend.mcp.host | quote }} MCP_NAME: {{ .Values.backend.mcp.name | quote }} MCP_CHAT_SIMPLE_DESCRIPTION: {{ .Values.backend.mcp.chatSimpleDescription | quote }} + MCP_CHAT_SIMPLE_RETURNS: {{ .Values.backend.mcp.chatSimpleReturns | quote }} + MCP_CHAT_SIMPLE_NOTES: {{ .Values.backend.mcp.chatSimpleNotes | quote }} + MCP_CHAT_SIMPLE_EXAMPLES: {{ .Values.backend.mcp.chatSimpleExamples | quote }} MCP_CHAT_WITH_HISTORY_DESCRIPTION: {{ .Values.backend.mcp.chatWithHistoryDescription | quote }} + MCP_CHAT_WITH_HISTORY_RETURNS: {{ .Values.backend.mcp.chatWithHistoryReturns | quote }} + MCP_CHAT_WITH_HISTORY_NOTES: {{ .Values.backend.mcp.chatWithHistoryNotes | quote }} + MCP_CHAT_WITH_HISTORY_EXAMPLES: {{ .Values.backend.mcp.chatWithHistoryExamples | quote }} diff --git a/infrastructure/rag/values.yaml b/infrastructure/rag/values.yaml index 4d9fdcaf..3e97c2f6 100644 --- a/infrastructure/rag/values.yaml +++ b/infrastructure/rag/values.yaml @@ -20,8 +20,26 @@ backend: name: "mcp" port: "8000" host: "0.0.0.0" - chatSimpleDescription: "Send a message to the RAG system and get a simple text response. This is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." - chatWithHistoryDescription: "Send a message with conversation history and get structured response. Provide conversation history as a simple list of dictionaries. Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + + # Chat simple tool configuration + chatSimpleDescription: "Send a message to the RAG system and get a simple text response.\n\nThis is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." + chatSimpleParameterDescriptions: + session_id: "Unique identifier for the chat session." + message: "The message/question to ask the RAG system." + chatSimpleReturns: "The answer from the RAG system as plain text." + chatSimpleNotes: "" + chatSimpleExamples: "" + + # Chat with history tool configuration + chatWithHistoryDescription: "Send a message with conversation history and get structured response.\n\nProvide conversation history as a simple list of dictionaries.\nEach history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + chatWithHistoryParameterDescriptions: + session_id: "Unique identifier for the chat session." + message: "The current message/question to ask." + history: "Previous conversation history. Each item should be:\n {\"role\": \"user\" or \"assistant\", \"message\": \"the message text\"}" + chatWithHistoryReturns: "Response containing:\n - answer: The response text\n - finish_reason: Why the response ended\n - citations: List of source documents used (simplified)" + chatWithHistoryNotes: "" + chatWithHistoryExamples: "" + image: repository: ghcr.io/stackitcloud/rag-template name: rag-mcp diff --git a/libs/rag-core-api/pyproject.toml b/libs/rag-core-api/pyproject.toml index 88952f1d..d61057a4 100644 --- a/libs/rag-core-api/pyproject.toml +++ b/libs/rag-core-api/pyproject.toml @@ -122,5 +122,5 @@ max-line-length = 120 log_cli = true log_cli_level = "DEBUG" pythonpath = ["src", "tests"] -testpaths = "tests" +testpaths = ["tests"] diff --git a/services/mcp-server/pyproject.toml b/services/mcp-server/pyproject.toml index 1db8abb5..dde89f0b 100644 --- a/services/mcp-server/pyproject.toml +++ b/services/mcp-server/pyproject.toml @@ -12,6 +12,7 @@ poetry = "^2.1.3" pydantic-settings = "^2.9.1" dependency-injector = "^4.46.0" python-dateutil = "^2.9.0.post0" +jinja2 = "^3.1.6" [tool.poetry.group.dev.dependencies] debugpy = "^1.8.1" @@ -95,3 +96,20 @@ skip_gitignore = true [tool.pylint] max-line-length = 120 + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src", "tests"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "--disable-warnings", + "--color=yes" +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests" +] + diff --git a/services/mcp-server/src/__init__.py b/services/mcp-server/src/__init__.py new file mode 100644 index 00000000..1d941a37 --- /dev/null +++ b/services/mcp-server/src/__init__.py @@ -0,0 +1 @@ +"""MCP Server source package.""" diff --git a/services/mcp-server/src/docstring_system.py b/services/mcp-server/src/docstring_system.py new file mode 100644 index 00000000..2dc12537 --- /dev/null +++ b/services/mcp-server/src/docstring_system.py @@ -0,0 +1,223 @@ +"""Extensible docstring system using Jinja2 templates and decorators.""" + +import functools +import inspect +from typing import Any, Callable, Dict, Optional + +from jinja2 import Environment, DictLoader +from pydantic_settings import BaseSettings + + +class DocstringTemplateSystem: + """System for managing extensible docstrings using Jinja2 templates.""" + + def __init__(self, settings: BaseSettings): + """Initialize the docstring template system. + + Parameters + ---------- + settings : BaseSettings + Settings object containing template configurations + """ + self.settings = settings + self.templates = {} + self._setup_templates() + + def _setup_templates(self): + """Set up the default Jinja2 templates.""" + # Base template that can be used for any function + base_template = '''{{ docstring_content }} + +Parameters +---------- +{% for param in parameters %} +{{ param.name }}: {{ param.type }} + {{ param.description }} +{% endfor %} + +Returns +------- +{{ returns.type }} + {{ returns.description }} +{% if notes %} + +Notes +----- +{{ notes }} +{% endif %} +{% if examples %} + +Examples +-------- +{{ examples }} +{% endif %}''' + + template_strings = { + 'chat_simple': base_template, + 'chat_with_history': base_template, + '_default': base_template # Default template for any function + } + + self.env = Environment(loader=DictLoader(template_strings)) + + def get_template(self, template_name: str): + """Get a Jinja2 template by name. + + Parameters + ---------- + template_name : str + Name of the template to retrieve + + Returns + ------- + jinja2.Template + The requested template + """ + try: + return self.env.get_template(template_name) + except Exception: + # Fallback to default template if specific template not found + return self.env.get_template('_default') + + def render_docstring(self, template_name: str, **kwargs) -> str: + """Render a docstring using the specified template. + + Parameters + ---------- + template_name : str + Name of the template to use + **kwargs + Variables to pass to the template + + Returns + ------- + str + The rendered docstring + """ + template = self.get_template(template_name) + return template.render(**kwargs) + + +def extensible_docstring(config_prefix: str): + """Decorator for creating extensible docstrings. + + Parameters + ---------- + config_prefix : str + Prefix for configuration attributes in settings (e.g., "chat_simple") + + Returns + ------- + Callable + Decorated function with dynamic docstring + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # Get the instance and settings from the method + def update_docstring(instance): + if hasattr(instance, '__docstring_system'): + docstring_system = instance.__docstring_system + + # Get docstring content from settings + docstring_content = "" + if hasattr(instance._settings, f"{config_prefix}_description"): + docstring_content = getattr(instance._settings, f"{config_prefix}_description") + + # Extract parameters from function signature + sig = inspect.signature(func) + parameters = [] + param_descriptions = {} + + # Get parameter descriptions from settings if available + if hasattr(instance._settings, f"{config_prefix}_parameter_descriptions"): + param_descriptions = getattr(instance._settings, f"{config_prefix}_parameter_descriptions") + + # Build parameter list from function signature + for param_name, param in sig.parameters.items(): + if param_name == 'self': # Skip 'self' parameter + continue + + # Get type annotation + param_type = "Any" + if param.annotation != inspect.Parameter.empty: + param_type = getattr(param.annotation, '__name__', str(param.annotation)) + + # Add default value info if present + if param.default != inspect.Parameter.empty: + if param.default is None: + param_type += ", optional" + else: + param_type += f", default={param.default}" + + # Get description from settings or use default + description = param_descriptions.get(param_name, f"Parameter {param_name}") + + parameters.append({ + "name": param_name, + "type": param_type, + "description": description + }) + + # Get return configuration from function signature and settings + return_type = "Any" + if func.__annotations__.get('return') is not None: + return_annotation = func.__annotations__['return'] + return_type = getattr(return_annotation, '__name__', str(return_annotation)) + + return_description = "Return value" + if hasattr(instance._settings, f"{config_prefix}_returns"): + return_description = getattr(instance._settings, f"{config_prefix}_returns") + + returns = {"type": return_type, "description": return_description} + + # Get optional notes + notes = "" + if hasattr(instance._settings, f"{config_prefix}_notes"): + notes = getattr(instance._settings, f"{config_prefix}_notes") + + # Get optional examples + examples = "" + if hasattr(instance._settings, f"{config_prefix}_examples"): + examples = getattr(instance._settings, f"{config_prefix}_examples") + + # Render the docstring using the function name as template + template_name = func.__name__ + rendered_docstring = docstring_system.render_docstring( + template_name, + docstring_content=docstring_content, + parameters=parameters, + returns=returns, + notes=notes, + examples=examples + ) + + wrapper.__doc__ = rendered_docstring + + # Store the update function for later use + wrapper._update_docstring = update_docstring + wrapper._original_func = func + + return wrapper + return decorator + + +def setup_extensible_docstrings(instance, docstring_system: DocstringTemplateSystem): + """Set up extensible docstrings for all decorated methods in an instance. + + Parameters + ---------- + instance : object + The instance to set up docstrings for + docstring_system : DocstringTemplateSystem + The docstring system to use for rendering + """ + instance.__docstring_system = docstring_system + + # Find all methods with extensible docstrings and update them + for attr_name in dir(instance): + attr = getattr(instance, attr_name) + if hasattr(attr, '_update_docstring'): + attr._update_docstring(instance) diff --git a/services/mcp-server/src/rag_mcp_server.py b/services/mcp-server/src/rag_mcp_server.py index 0d3e6f67..fc6df3a5 100644 --- a/services/mcp-server/src/rag_mcp_server.py +++ b/services/mcp-server/src/rag_mcp_server.py @@ -11,6 +11,8 @@ from rag_backend_client.openapi_client.models.chat_role import ChatRole from typing import Any +from docstring_system import DocstringTemplateSystem, extensible_docstring, setup_extensible_docstrings + logger = logging.getLogger(__name__) @@ -23,6 +25,11 @@ def __init__(self, api_client: RagApi, mcp_server: FastMCP, settings: BaseSettin self._api_client = api_client self._server = mcp_server self._settings = settings + + # Initialize the docstring system + docstring_system = DocstringTemplateSystem(settings) + setup_extensible_docstrings(self, docstring_system) + self._register_tools() def run(self): @@ -30,49 +37,42 @@ def run(self): logger.info(f"Starting FastMCP Server on {self.TRANSPORT}://{self._settings.host}:{self._settings.port}") self._server.run(transport=self.TRANSPORT, host=self._settings.host, port=self._settings.port) + @extensible_docstring("chat_simple") + async def chat_simple(self, session_id: str, message: str) -> str: + chat_request = ChatRequest(message=message) + response = await self._handle_chat(session_id, chat_request) + return response.answer + + @extensible_docstring("chat_with_history") + async def chat_with_history( + self, session_id: str, message: str, history: list[dict[str, str]] = None + ) -> dict[str, Any]: + # Build chat history if provided + chat_history = None + if history: + history_messages = [] + for item in history: + role = ChatRole.USER if item.get("role", "").lower() == "user" else ChatRole.ASSISTANT + history_messages.append(ChatHistoryMessage(role=role, message=item["message"])) + chat_history = ChatHistory(messages=history_messages) + + chat_request = ChatRequest(message=message, history=chat_history) + response = await self._handle_chat(session_id, chat_request) + + # Simplify citations for easier consumption + simplified_citations = [] + for citation in response.citations: + simplified_citations.append( + {"content": citation.page_content, "metadata": {pair.key: pair.value for pair in citation.metadata}} + ) + + return {"answer": response.answer, "finish_reason": response.finish_reason, "citations": simplified_citations} + def _register_tools(self): """Register all MCP tools with the server.""" - - async def chat_simple(session_id: str, message: str) -> str: - chat_request = ChatRequest(message=message) - response = await self._handle_chat(session_id, chat_request) - return response.answer - - chat_simple.__doc__ = self._settings.chat_simple_description - - async def chat_with_history( - session_id: str, message: str, history: list[dict[str, str]] = None - ) -> dict[str, Any]: - # Build chat history if provided - chat_history = None - if history: - history_messages = [] - for item in history: - role = ChatRole.USER if item.get("role", "").lower() == "user" else ChatRole.ASSISTANT - history_messages.append(ChatHistoryMessage(role=role, message=item["message"])) - chat_history = ChatHistory(messages=history_messages) - - chat_request = ChatRequest(message=message, history=chat_history) - response = await self._handle_chat(session_id, chat_request) - - # Simplify citations for easier consumption - simplified_citations = [] - for citation in response.citations: - simplified_citations.append( - {"content": citation.page_content, "metadata": {pair.key: pair.value for pair in citation.metadata}} - ) - - return { - "answer": response.answer, - "finish_reason": response.finish_reason, - "citations": simplified_citations, - } - - chat_with_history.__doc__ = self._settings.chat_with_history_description - # Simple tools for basic agent usage - self._server.add_tool(self._server.tool(chat_simple)) - self._server.add_tool(self._server.tool(chat_with_history)) + self._server.add_tool(self._server.tool(self.chat_simple)) + self._server.add_tool(self._server.tool(self.chat_with_history)) async def _handle_chat(self, session_id: str, chat_request: ChatRequest) -> ChatResponse: """Handle the chat request with the RAG backend.""" diff --git a/services/mcp-server/src/settings/mcp_settings.py b/services/mcp-server/src/settings/mcp_settings.py index 0b774ec0..e26d349d 100644 --- a/services/mcp-server/src/settings/mcp_settings.py +++ b/services/mcp-server/src/settings/mcp_settings.py @@ -1,8 +1,8 @@ -"""Module that contains settings for the Fake LLM.""" +"""Module that contains settings for the MCP server.""" from pydantic import Field from pydantic_settings import BaseSettings - +from typing import Dict class MCPSettings(BaseSettings): """ @@ -31,17 +31,36 @@ class Config: host: str = Field(default="0.0.0.0") port: int = Field(default=8000) name: str = Field(default="RAG MCP server") - chat_simple_description: str = Field( - default="""Send a message to the RAG system and get a simple text response. - This is the simplest way to interact with the RAG system - just provide a message - and get back the answer as plain text. - """ + # Chat Simple Method Configuration + chat_simple_description: str = Field( + default="Send a message to the RAG system and get a simple text response.\n\nThis is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." ) - chat_with_history_description: str = Field( - default="""Send a message with conversation history and get structured response. + chat_simple_parameter_descriptions: Dict[str, str] = Field( + default_factory=lambda: { + "session_id": "Unique identifier for the chat session.", + "message": "The message/question to ask the RAG system." + } + ) + chat_simple_returns: str = Field( + default="The answer from the RAG system as plain text." + ) + chat_simple_notes: str = Field(default="") + chat_simple_examples: str = Field(default="") - Provide conversation history as a simple list of dictionaries. - Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys. - """ + # Chat With History Method Configuration + chat_with_history_description: str = Field( + default="Send a message with conversation history and get structured response.\n\nProvide conversation history as a simple list of dictionaries.\nEach history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + ) + chat_with_history_parameter_descriptions: Dict[str, str] = Field( + default_factory=lambda: { + "session_id": "Unique identifier for the chat session.", + "message": "The current message/question to ask.", + "history": "Previous conversation history. Each item should be:\n {\"role\": \"user\" or \"assistant\", \"message\": \"the message text\"}" + } + ) + chat_with_history_returns: str = Field( + default="Response containing:\n - answer: The response text\n - finish_reason: Why the response ended\n - citations: List of source documents used (simplified)" ) + chat_with_history_notes: str = Field(default="") + chat_with_history_examples: str = Field(default="") diff --git a/services/mcp-server/tests/docstring_system_test.py b/services/mcp-server/tests/docstring_system_test.py new file mode 100644 index 00000000..ff83247f --- /dev/null +++ b/services/mcp-server/tests/docstring_system_test.py @@ -0,0 +1,370 @@ +import pytest + +from src.docstring_system import DocstringTemplateSystem, extensible_docstring, setup_extensible_docstrings +from src.settings.mcp_settings import MCPSettings + + +# Fixtures +@pytest.fixture +def settings(): + """Create a default MCPSettings instance.""" + return MCPSettings() + + +@pytest.fixture +def docstring_system(settings): + """Create a DocstringTemplateSystem instance.""" + return DocstringTemplateSystem(settings) + + +@pytest.fixture +def test_class_factory(): + """Factory fixture for creating test classes.""" + def _create_test_class(settings): + class TestClass: + def __init__(self, settings): + self._settings = settings + docstring_system = DocstringTemplateSystem(settings) + setup_extensible_docstrings(self, docstring_system) + + @extensible_docstring("chat_simple") + def chat_simple(self, session_id: str, message: str) -> str: + return f"Response for {session_id}: {message}" + + @extensible_docstring("chat_with_history") + def chat_with_history(self, session_id: str, message: str, history: list = None) -> dict: + return {"answer": f"Response for {session_id}: {message}", "citations": []} + + return TestClass(settings) + return _create_test_class + + +# Tests for DocstringTemplateSystem +def test_docstring_system_initialization(settings, docstring_system): + """Test that DocstringTemplateSystem initializes correctly.""" + assert docstring_system.env is not None + assert docstring_system.settings is not None + assert docstring_system.settings == settings + + +def test_get_template(docstring_system): + """Test template retrieval.""" + template = docstring_system.get_template('chat_simple') + assert template is not None + + template = docstring_system.get_template('chat_with_history') + assert template is not None + + +def test_render_docstring_basic(docstring_system): + """Test basic docstring rendering.""" + result = docstring_system.render_docstring( + 'chat_simple', + docstring_content="Test description", + parameters=[], + returns={"type": "str", "description": "Test return"}, + notes="", + examples="" + ) + + assert "Test description" in result + assert "Parameters" in result + assert "Returns" in result + assert "str" in result + assert "Test return" in result + + +def test_render_docstring_with_parameters(docstring_system): + """Test docstring rendering with parameters.""" + parameters = [ + {"name": "param1", "type": "str", "description": "First parameter"}, + {"name": "param2", "type": "int", "description": "Second parameter"} + ] + + result = docstring_system.render_docstring( + 'chat_simple', + docstring_content="Test description", + parameters=parameters, + returns={"type": "str", "description": "Test return"}, + notes="", + examples="" + ) + + assert "param1: str" in result + assert "First parameter" in result + assert "param2: int" in result + assert "Second parameter" in result + + +def test_render_docstring_with_notes_and_examples(docstring_system): + """Test docstring rendering with notes and examples.""" + result = docstring_system.render_docstring( + 'chat_simple', + docstring_content="Test description", + parameters=[], + returns={"type": "str", "description": "Test return"}, + notes="This is a test note", + examples=">>> example_function()\n'result'" + ) + + assert "Notes" in result + assert "This is a test note" in result + assert "Examples" in result + assert ">>> example_function()" in result + + +def test_render_docstring_without_notes_and_examples(docstring_system): + """Test docstring rendering without notes and examples.""" + result = docstring_system.render_docstring( + 'chat_simple', + docstring_content="Test description", + parameters=[], + returns={"type": "str", "description": "Test return"}, + notes="", + examples="" + ) + + assert "Notes" not in result + assert "Examples" not in result + + +# Tests for extensible_docstring decorator +def test_decorator_creation(): + """Test that the decorator can be created.""" + decorator = extensible_docstring("chat_simple") + assert decorator is not None + + # Test that it can decorate a function + @decorator + def test_function(): + pass + + assert test_function is not None + assert hasattr(test_function, '_update_docstring') + assert hasattr(test_function, '_original_func') + + +def test_decorator_preserves_function_metadata(): + """Test that the decorator preserves function metadata.""" + @extensible_docstring("chat_simple") + def test_function(param1: str, param2: int) -> str: + """Original docstring.""" + return "test" + + assert test_function.__name__ == "test_function" + assert test_function._original_func.__name__ == "test_function" + + +def test_decorator_function_execution(): + """Test that decorated functions still execute correctly.""" + @extensible_docstring("chat_simple") + def test_function(x: int) -> int: + return x * 2 + + result = test_function(5) + assert result == 10 + + +# Tests for MCPSettings +def test_default_settings(settings): + """Test that default settings are loaded correctly.""" + # Test basic settings + assert settings.host == "0.0.0.0" + assert settings.port == 8000 + assert settings.name == "RAG MCP server" + + # Test chat_simple settings + assert settings.chat_simple_description is not None + assert isinstance(settings.chat_simple_parameter_descriptions, dict) + assert isinstance(settings.chat_simple_returns, str) + assert settings.chat_simple_notes == "" + assert settings.chat_simple_examples == "" + + # Test chat_with_history settings + assert settings.chat_with_history_description is not None + assert isinstance(settings.chat_with_history_parameter_descriptions, dict) + assert isinstance(settings.chat_with_history_returns, str) + assert settings.chat_with_history_notes == "" + assert settings.chat_with_history_examples == "" + + +def test_parameter_descriptions(): + """Test parameter descriptions configuration.""" + param_descriptions = { + "test_param": "Test description for parameter" + } + + settings = MCPSettings( + chat_simple_parameter_descriptions=param_descriptions + ) + + assert settings.chat_simple_parameter_descriptions == param_descriptions + + +def test_return_config(): + """Test return description configuration.""" + return_desc = "Test return description" + assert return_desc == "Test return description" + + +def test_custom_settings(): + """Test custom settings override.""" + custom_param_descriptions = { + "custom_param": "Custom parameter description" + } + custom_returns = "Custom return description" + + settings = MCPSettings( + chat_simple_description="Custom description", + chat_simple_parameter_descriptions=custom_param_descriptions, + chat_simple_returns=custom_returns, + chat_simple_notes="Custom notes", + chat_simple_examples="Custom examples" + ) + + assert settings.chat_simple_description == "Custom description" + assert settings.chat_simple_parameter_descriptions == custom_param_descriptions + assert settings.chat_simple_returns == custom_returns + assert settings.chat_simple_notes == "Custom notes" + assert settings.chat_simple_examples == "Custom examples" + + +# Integration tests +def test_setup_extensible_docstrings(settings, test_class_factory): + """Test that setup_extensible_docstrings works correctly.""" + instance = test_class_factory(settings) + + # Check that the docstring system is set up + assert hasattr(instance, '__docstring_system') + + # Check that docstrings are generated + assert instance.chat_simple.__doc__ is not None + assert instance.chat_with_history.__doc__ is not None + + +def test_generated_docstrings_content(settings, test_class_factory): + """Test that generated docstrings contain expected content.""" + instance = test_class_factory(settings) + + # Test chat_simple docstring + simple_doc = instance.chat_simple.__doc__ + assert "Send a message to the RAG system" in simple_doc + assert "Parameters" in simple_doc + assert "session_id: str" in simple_doc + assert "message: str" in simple_doc + assert "Returns" in simple_doc + assert "str" in simple_doc + + # Test chat_with_history docstring + history_doc = instance.chat_with_history.__doc__ + assert "Send a message with conversation history" in history_doc + assert "Parameters" in history_doc + assert "session_id: str" in history_doc + assert "message: str" in history_doc + assert "history: list, optional" in history_doc + assert "Returns" in history_doc + assert "dict" in history_doc # Return type from function signature + + +def test_custom_configuration(test_class_factory): + """Test that custom configuration works.""" + # Create custom settings + custom_settings = MCPSettings( + chat_simple_description="Custom simple description", + chat_simple_parameter_descriptions={ + "session_id": "Custom session parameter", + "message": "Custom message parameter" + }, + chat_simple_returns="Custom return value", + chat_simple_notes="Custom notes section", + chat_simple_examples=">>> custom_example()\n'result'" + ) + + instance = test_class_factory(custom_settings) + + # Check that custom content is in the docstring + simple_doc = instance.chat_simple.__doc__ + assert "Custom simple description" in simple_doc + assert "session_id: str" in simple_doc + assert "Custom session parameter" in simple_doc + assert "message: str" in simple_doc + assert "Custom message parameter" in simple_doc + assert "str" in simple_doc # Return type from function signature + assert "Custom return value" in simple_doc + assert "Notes" in simple_doc + assert "Custom notes section" in simple_doc + assert "Examples" in simple_doc + assert ">>> custom_example()" in simple_doc + + +def test_function_execution_still_works(settings, test_class_factory): + """Test that decorated functions still execute correctly.""" + instance = test_class_factory(settings) + + # Test chat_simple execution + result = instance.chat_simple("test_session", "test_message") + assert result == "Response for test_session: test_message" + + # Test chat_with_history execution + result = instance.chat_with_history("test_session", "test_message", []) + expected = {"answer": "Response for test_session: test_message", "citations": []} + assert result == expected + + +# Edge case tests +def test_missing_settings_attributes(): + """Test behavior when settings attributes are missing.""" + # Create minimal settings without some attributes + settings = MCPSettings() + + # Remove some attributes to test handling + delattr(settings, 'chat_simple_notes') + delattr(settings, 'chat_simple_examples') + + class TestClass: + def __init__(self, settings): + self._settings = settings + docstring_system = DocstringTemplateSystem(settings) + setup_extensible_docstrings(self, docstring_system) + + @extensible_docstring("chat_simple") + def test_method(self): + pass + + # Should not raise an exception + instance = TestClass(settings) + assert instance.test_method.__doc__ is not None + + +def test_empty_configuration(): + """Test behavior with empty configuration.""" + settings = MCPSettings( + chat_simple_description="", + chat_simple_parameter_descriptions={}, + chat_simple_notes="", + chat_simple_examples="" + ) + + class TestClass: + def __init__(self, settings): + self._settings = settings + docstring_system = DocstringTemplateSystem(settings) + setup_extensible_docstrings(self, docstring_system) + + @extensible_docstring("chat_simple") + def test_method(self): + pass + + instance = TestClass(settings) + doc = instance.test_method.__doc__ + + # Should still have structure but with empty content + assert "Parameters" in doc + assert "Returns" in doc + assert "Notes" not in doc + assert "Examples" not in doc + + +if __name__ == "__main__": + # Run pytest with verbose output + pytest.main([__file__, "-v"]) diff --git a/services/mcp-server/tests/dummy7_test.py b/services/mcp-server/tests/dummy7_test.py deleted file mode 100644 index 1428394b..00000000 --- a/services/mcp-server/tests/dummy7_test.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_dummy() -> None: - print("Dummy test.") - assert True From 6d53a9cd36512bfb398028f896ec161dc7aa6e13 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 11:34:15 +0200 Subject: [PATCH 3/8] feat: add customizable tool documentation configuration for MCP server --- README.md | 21 ++++++++++ services/mcp-server/README.md | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/README.md b/README.md index b20d1bcd..9f4e9d4d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,27 @@ The MCP server runs as a sidecar container alongside the main RAG backend and ex - `chat_simple`: Basic question-answering without conversation history - `chat_with_history`: Advanced chat interface with conversation history and returns structured responses with `answer`, `finish_reason`, and `citations`. +##### Configuring Tool Documentation + +The MCP server supports customizable documentation for its tools through environment variables. This allows you to customize the descriptions, parameter explanations, and examples shown to MCP clients. All documentation configuration uses the `MCP_` prefix and can be configured with the [values.yaml](infrastructure/rag/values.yaml). The following configuration options exist: + +**For `chat_simple` tool:** + +- `MCP_CHAT_SIMPLE_DESCRIPTION`: Main description of the tool +- `MCP_CHAT_SIMPLE_PARAMETER_DESCRIPTIONS`: JSON object mapping parameter names to descriptions +- `MCP_CHAT_SIMPLE_RETURNS`: Description of the return value +- `MCP_CHAT_SIMPLE_NOTES`: Additional notes about the tool +- `MCP_CHAT_SIMPLE_EXAMPLES`: Usage examples + +**For `chat_with_history` tool:** + +- `MCP_CHAT_WITH_HISTORY_DESCRIPTION`: Main description of the tool +- `MCP_CHAT_WITH_HISTORY_PARAMETER_DESCRIPTIONS`: JSON object mapping parameter names to descriptions +- `MCP_CHAT_WITH_HISTORY_RETURNS`: Description of the return value +- `MCP_CHAT_WITH_HISTORY_NOTES`: Additional notes about the tool +- `MCP_CHAT_WITH_HISTORY_EXAMPLES`: Usage examples + + For further information on configuration and usage, please consult the [MCP Server README](./services/mcp-server/README.md). #### 1.1.5 Frontend diff --git a/services/mcp-server/README.md b/services/mcp-server/README.md index 6e8e2348..ec83779d 100644 --- a/services/mcp-server/README.md +++ b/services/mcp-server/README.md @@ -78,6 +78,79 @@ The server supports configuration through environment variables with the followi - `BACKEND_BASE_PATH`: RAG backend URL (default: `http://127.0.0.1:8080`) +### Tool Documentation Configuration + +The MCP server supports customizable documentation for its tools through environment variables. This allows you to customize the descriptions, parameter explanations, and examples shown to MCP clients. + +#### Chat Simple Tool Documentation + +- `MCP_CHAT_SIMPLE_DESCRIPTION`: Main description of the tool +- `MCP_CHAT_SIMPLE_PARAMETER_DESCRIPTIONS`: JSON object mapping parameter names to descriptions +- `MCP_CHAT_SIMPLE_RETURNS`: Description of the return value +- `MCP_CHAT_SIMPLE_NOTES`: Additional notes about the tool +- `MCP_CHAT_SIMPLE_EXAMPLES`: Usage examples + +#### Chat With History Tool Documentation + +- `MCP_CHAT_WITH_HISTORY_DESCRIPTION`: Main description of the tool +- `MCP_CHAT_WITH_HISTORY_PARAMETER_DESCRIPTIONS`: JSON object mapping parameter names to descriptions +- `MCP_CHAT_WITH_HISTORY_RETURNS`: Description of the return value +- `MCP_CHAT_WITH_HISTORY_NOTES`: Additional notes about the tool +- `MCP_CHAT_WITH_HISTORY_EXAMPLES`: Usage examples + +#### Example Configuration + +```env +# Custom tool descriptions +MCP_CHAT_SIMPLE_DESCRIPTION="Ask questions about your documents and get instant answers." +MCP_CHAT_SIMPLE_EXAMPLES="chat_simple(session_id='my-session', message='What is the main topic of the document?')" + +# Custom parameter descriptions (JSON format) +MCP_CHAT_SIMPLE_PARAMETER_DESCRIPTIONS='{"session_id": "A unique session identifier for your conversation", "message": "Your question about the documents"}' + +# Custom return description +MCP_CHAT_SIMPLE_RETURNS="A plain text answer based on your document content" + +# Notes about usage +MCP_CHAT_SIMPLE_NOTES="This tool is best for simple questions that don't require conversation context." +``` + +#### Template Rendering + +The MCP server uses Jinja2 templates to generate tool documentation in **numpy docstring format**. When the environment variables are set, they are rendered into a structured docstring that MCP clients can parse and display. + +**Example of rendered docstring:** + +Given the configuration above, the `chat_simple` tool's docstring would be rendered as: + +```python +def chat_simple(session_id: str, message: str) -> str: + """Ask questions about your documents and get instant answers. + + Parameters + ---------- + session_id : str + A unique session identifier for your conversation + message : str + Your question about the documents + + Returns + ------- + str + A plain text answer based on your document content + + Notes + ----- + This tool is best for simple questions that don't require conversation context. + + Examples + -------- + chat_simple(session_id='my-session', message='What is the main topic of the document?') + """ +``` + +This numpy-style docstring format ensures compatibility with documentation tools and provides clear, structured information to MCP clients about how to use each tool. + ## Deployment The MCP server is designed to be deployed alongside the main RAG backend as a sidecar container. A detailed explanation of the deployment can be found in the [main README](../README.md) and the [infrastructure README](../rag-infrastructure/README.md) of the project. From 76db7f2270c066ff80d170b0f9a1882be48d5bab Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 11:47:34 +0200 Subject: [PATCH 4/8] refactor: simplify docstring template setup and rendering process --- services/mcp-server/src/docstring_system.py | 50 +++------------------ 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/services/mcp-server/src/docstring_system.py b/services/mcp-server/src/docstring_system.py index 2dc12537..7b827629 100644 --- a/services/mcp-server/src/docstring_system.py +++ b/services/mcp-server/src/docstring_system.py @@ -25,8 +25,8 @@ def __init__(self, settings: BaseSettings): def _setup_templates(self): """Set up the default Jinja2 templates.""" - # Base template that can be used for any function - base_template = '''{{ docstring_content }} + # Single template string + self.template_string = '''{{ docstring_content }} Parameters ---------- @@ -52,50 +52,12 @@ def _setup_templates(self): {{ examples }} {% endif %}''' - template_strings = { - 'chat_simple': base_template, - 'chat_with_history': base_template, - '_default': base_template # Default template for any function - } - - self.env = Environment(loader=DictLoader(template_strings)) - - def get_template(self, template_name: str): - """Get a Jinja2 template by name. - - Parameters - ---------- - template_name : str - Name of the template to retrieve - - Returns - ------- - jinja2.Template - The requested template - """ - try: - return self.env.get_template(template_name) - except Exception: - # Fallback to default template if specific template not found - return self.env.get_template('_default') + self.env = Environment() + self.template = self.env.from_string(self.template_string) def render_docstring(self, template_name: str, **kwargs) -> str: - """Render a docstring using the specified template. - - Parameters - ---------- - template_name : str - Name of the template to use - **kwargs - Variables to pass to the template - - Returns - ------- - str - The rendered docstring - """ - template = self.get_template(template_name) - return template.render(**kwargs) + """Render a docstring using the template.""" + return self.template.render(**kwargs) def extensible_docstring(config_prefix: str): From f3b7c03344c10499bef5d264d29f66acb5092855 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 12:39:09 +0200 Subject: [PATCH 5/8] feat: Enhance MCP server documentation and configuration - Updated configmap.yaml to include new environment variables for chat tools. - Expanded README.md with detailed Helm chart configuration for MCP server. - Added Jinja2 and MarkupSafe dependencies to poetry.lock for template rendering. - Modified mcp_settings.py to improve parameter descriptions and default values. - Refactored docstring_system.py to streamline docstring rendering and parameter extraction. - Improved test coverage in docstring_system_test.py for new configurations and behaviors. --- .../rag/templates/backend/configmap.yaml | 2 + services/mcp-server/README.md | 43 ++++ services/mcp-server/poetry.lock | 91 ++++++- services/mcp-server/pyproject.toml | 2 +- services/mcp-server/src/docstring_system.py | 224 +++++++++++------- services/mcp-server/src/rag_mcp_server.py | 4 +- .../mcp-server/src/settings/mcp_settings.py | 36 ++- .../mcp-server/tests/docstring_system_test.py | 61 +++-- 8 files changed, 329 insertions(+), 134 deletions(-) diff --git a/infrastructure/rag/templates/backend/configmap.yaml b/infrastructure/rag/templates/backend/configmap.yaml index fa2db76a..c5621c64 100644 --- a/infrastructure/rag/templates/backend/configmap.yaml +++ b/infrastructure/rag/templates/backend/configmap.yaml @@ -143,10 +143,12 @@ data: MCP_HOST: {{ .Values.backend.mcp.host | quote }} MCP_NAME: {{ .Values.backend.mcp.name | quote }} MCP_CHAT_SIMPLE_DESCRIPTION: {{ .Values.backend.mcp.chatSimpleDescription | quote }} + MCP_CHAT_SIMPLE_PARAMETER_DESCRIPTIONS: {{ .Values.backend.mcp.chatSimpleParameterDescriptions | toJson | quote }} MCP_CHAT_SIMPLE_RETURNS: {{ .Values.backend.mcp.chatSimpleReturns | quote }} MCP_CHAT_SIMPLE_NOTES: {{ .Values.backend.mcp.chatSimpleNotes | quote }} MCP_CHAT_SIMPLE_EXAMPLES: {{ .Values.backend.mcp.chatSimpleExamples | quote }} MCP_CHAT_WITH_HISTORY_DESCRIPTION: {{ .Values.backend.mcp.chatWithHistoryDescription | quote }} + MCP_CHAT_WITH_HISTORY_PARAMETER_DESCRIPTIONS: {{ .Values.backend.mcp.chatWithHistoryParameterDescriptions | toJson | quote }} MCP_CHAT_WITH_HISTORY_RETURNS: {{ .Values.backend.mcp.chatWithHistoryReturns | quote }} MCP_CHAT_WITH_HISTORY_NOTES: {{ .Values.backend.mcp.chatWithHistoryNotes | quote }} MCP_CHAT_WITH_HISTORY_EXAMPLES: {{ .Values.backend.mcp.chatWithHistoryExamples | quote }} diff --git a/services/mcp-server/README.md b/services/mcp-server/README.md index ec83779d..4654c33b 100644 --- a/services/mcp-server/README.md +++ b/services/mcp-server/README.md @@ -151,6 +151,49 @@ def chat_simple(session_id: str, message: str) -> str: This numpy-style docstring format ensures compatibility with documentation tools and provides clear, structured information to MCP clients about how to use each tool. +### Helm Chart Configuration + +For production deployments, the MCP server documentation can be configured through the Helm chart's `values.yaml` file. This provides a structured way to manage tool documentation across different environments. + +The MCP configuration is located under `backend.mcp` in the [values.yaml](../../infrastructure/rag/values.yaml) file: + +```yaml +backend: + mcp: + # Basic MCP server settings + name: "mcp" + port: "8000" + host: "0.0.0.0" + + # Chat simple tool configuration + chatSimpleDescription: "Send a message to the RAG system and get a simple text response.\n\nThis is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." + chatSimpleParameterDescriptions: + session_id: "Unique identifier for the chat session." + message: "The message/question to ask the RAG system." + chatSimpleReturns: "The answer from the RAG system as plain text." + chatSimpleNotes: "" + chatSimpleExamples: "" + + # Chat with history tool configuration + chatWithHistoryDescription: "Send a message with conversation history and get structured response.\n\nProvide conversation history as a simple list of dictionaries.\nEach history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + chatWithHistoryParameterDescriptions: + session_id: "Unique identifier for the chat session." + message: "The current message/question to ask." + history: "Previous conversation history. Each item should be:\n {\"role\": \"user\" or \"assistant\", \"message\": \"the message text\"}" + chatWithHistoryReturns: "Response containing:\n - answer: The response text\n - finish_reason: Why the response ended\n - citations: List of source documents used (simplified)" + chatWithHistoryNotes: "" + chatWithHistoryExamples: "" +``` + +These values are automatically converted to the appropriate environment variables (with `MCP_` prefix) when the Helm chart is deployed. The `chatSimpleParameterDescriptions` and `chatWithHistoryParameterDescriptions` dictionaries are automatically converted to JSON format for consumption by the MCP server. + +This approach allows you to: + +- Manage documentation consistently across environments +- Version control your tool documentation +- Use different documentation for different deployments (dev, staging, production) +- Leverage Helm's templating features for dynamic documentation + ## Deployment The MCP server is designed to be deployed alongside the main RAG backend as a sidecar container. A detailed explanation of the deployment can be found in the [main README](../README.md) and the [infrastructure README](../rag-infrastructure/README.md) of the project. diff --git a/services/mcp-server/poetry.lock b/services/mcp-server/poetry.lock index 5dbc2d08..42eccfd5 100644 --- a/services/mcp-server/poetry.lock +++ b/services/mcp-server/poetry.lock @@ -1459,6 +1459,24 @@ files = [ test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] trio = ["trio"] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "keyring" version = "25.6.0" @@ -1514,6 +1532,77 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -2851,4 +2940,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "f48fb17b734be5d6ae331b5f4761727ad0726c42c0f13f5a575383739c1d9f56" +content-hash = "b5b92e224e2bbbd3b62f35c33ad1a68ed2f7351704e1875e316ed89290c111bd" diff --git a/services/mcp-server/pyproject.toml b/services/mcp-server/pyproject.toml index dde89f0b..95fb4949 100644 --- a/services/mcp-server/pyproject.toml +++ b/services/mcp-server/pyproject.toml @@ -61,7 +61,7 @@ multiline-quotes = '"""' dictionaries = ["en_US", "python", "technical", "pandas"] ban-relative-imports = true per-file-ignores = """ - ./tests/*: S101, + ./tests/*: S101,S104 ./src/settings/mcp_settings.py: S104, """ diff --git a/services/mcp-server/src/docstring_system.py b/services/mcp-server/src/docstring_system.py index 7b827629..57d09757 100644 --- a/services/mcp-server/src/docstring_system.py +++ b/services/mcp-server/src/docstring_system.py @@ -2,9 +2,9 @@ import functools import inspect -from typing import Any, Callable, Dict, Optional +from typing import Callable -from jinja2 import Environment, DictLoader +from jinja2 import Environment from pydantic_settings import BaseSettings @@ -23,10 +23,14 @@ def __init__(self, settings: BaseSettings): self.templates = {} self._setup_templates() + def render_docstring(self, template_name: str, **kwargs) -> str: + """Render a docstring using the template.""" + return self.template.render(**kwargs) + def _setup_templates(self): """Set up the default Jinja2 templates.""" # Single template string - self.template_string = '''{{ docstring_content }} + self.template_string = """{{ docstring_content }} Parameters ---------- @@ -50,14 +54,104 @@ def _setup_templates(self): Examples -------- {{ examples }} -{% endif %}''' +{% endif %}""" - self.env = Environment() + self.env = Environment(autoescape=True) self.template = self.env.from_string(self.template_string) - def render_docstring(self, template_name: str, **kwargs) -> str: - """Render a docstring using the template.""" - return self.template.render(**kwargs) + +def _extract_parameter_info(sig: inspect.Signature, param_descriptions: dict) -> list[dict]: + """Extract parameter information from function signature. + + Parameters + ---------- + sig : inspect.Signature + Function signature to extract parameters from + param_descriptions : dict + Dictionary mapping parameter names to descriptions + + Returns + ------- + list[dict] + List of parameter dictionaries with name, type, and description + """ + parameters = [] + + for param_name, param in sig.parameters.items(): + if param_name == "self": # Skip 'self' parameter + continue + + # Get type annotation + param_type = "Any" + if param.annotation != inspect.Parameter.empty: + param_type = getattr(param.annotation, "__name__", str(param.annotation)) + + # Add default value info if present + if param.default != inspect.Parameter.empty: + if param.default is None: + param_type += ", optional" + else: + param_type += f", default={param.default}" + + # Get description from settings or use default + description = param_descriptions.get(param_name, f"Parameter {param_name}") + parameters.append({"name": param_name, "type": param_type, "description": description}) + + return parameters + + +def _get_return_info(func: Callable, settings, config_prefix: str) -> dict: + """Get return type and description information. + + Parameters + ---------- + func : Callable + Function to get return info for + settings : object + Settings object containing configuration + config_prefix : str + Configuration prefix for settings lookup + + Returns + ------- + dict + Dictionary with return type and description + """ + return_type = "Any" + if func.__annotations__.get("return") is not None: + return_annotation = func.__annotations__["return"] + return_type = getattr(return_annotation, "__name__", str(return_annotation)) + + return_description = "Return value" + if hasattr(settings, f"{config_prefix}_returns"): + return_description = getattr(settings, f"{config_prefix}_returns") + + return {"type": return_type, "description": return_description} + + +def _get_settings_value(settings, config_prefix: str, suffix: str, default: str = "") -> str: + """Get a value from settings with the given prefix and suffix. + + Parameters + ---------- + settings : object + Settings object to get value from + config_prefix : str + Configuration prefix + suffix : str + Configuration suffix + default : str, optional + Default value if setting not found + + Returns + ------- + str + The settings value or default + """ + attr_name = f"{config_prefix}_{suffix}" + if hasattr(settings, attr_name): + return getattr(settings, attr_name) + return default def extensible_docstring(config_prefix: str): @@ -73,6 +167,7 @@ def extensible_docstring(config_prefix: str): Callable Decorated function with dynamic docstring """ + def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs): @@ -80,89 +175,46 @@ def wrapper(*args, **kwargs): # Get the instance and settings from the method def update_docstring(instance): - if hasattr(instance, '__docstring_system'): - docstring_system = instance.__docstring_system - - # Get docstring content from settings - docstring_content = "" - if hasattr(instance._settings, f"{config_prefix}_description"): - docstring_content = getattr(instance._settings, f"{config_prefix}_description") - - # Extract parameters from function signature - sig = inspect.signature(func) - parameters = [] - param_descriptions = {} - - # Get parameter descriptions from settings if available - if hasattr(instance._settings, f"{config_prefix}_parameter_descriptions"): - param_descriptions = getattr(instance._settings, f"{config_prefix}_parameter_descriptions") - - # Build parameter list from function signature - for param_name, param in sig.parameters.items(): - if param_name == 'self': # Skip 'self' parameter - continue - - # Get type annotation - param_type = "Any" - if param.annotation != inspect.Parameter.empty: - param_type = getattr(param.annotation, '__name__', str(param.annotation)) - - # Add default value info if present - if param.default != inspect.Parameter.empty: - if param.default is None: - param_type += ", optional" - else: - param_type += f", default={param.default}" - - # Get description from settings or use default - description = param_descriptions.get(param_name, f"Parameter {param_name}") - - parameters.append({ - "name": param_name, - "type": param_type, - "description": description - }) - - # Get return configuration from function signature and settings - return_type = "Any" - if func.__annotations__.get('return') is not None: - return_annotation = func.__annotations__['return'] - return_type = getattr(return_annotation, '__name__', str(return_annotation)) - - return_description = "Return value" - if hasattr(instance._settings, f"{config_prefix}_returns"): - return_description = getattr(instance._settings, f"{config_prefix}_returns") - - returns = {"type": return_type, "description": return_description} - - # Get optional notes - notes = "" - if hasattr(instance._settings, f"{config_prefix}_notes"): - notes = getattr(instance._settings, f"{config_prefix}_notes") - - # Get optional examples - examples = "" - if hasattr(instance._settings, f"{config_prefix}_examples"): - examples = getattr(instance._settings, f"{config_prefix}_examples") - - # Render the docstring using the function name as template - template_name = func.__name__ - rendered_docstring = docstring_system.render_docstring( - template_name, - docstring_content=docstring_content, - parameters=parameters, - returns=returns, - notes=notes, - examples=examples - ) - - wrapper.__doc__ = rendered_docstring + if not hasattr(instance, "__docstring_system"): + return + + docstring_system = instance.__docstring_system + + # Get docstring content from settings + docstring_content = _get_settings_value(instance._settings, config_prefix, "description") + + # Extract parameters from function signature + sig = inspect.signature(func) + param_descriptions = {} + if hasattr(instance._settings, f"{config_prefix}_parameter_descriptions"): + param_descriptions = getattr(instance._settings, f"{config_prefix}_parameter_descriptions") + + parameters = _extract_parameter_info(sig, param_descriptions) + returns = _get_return_info(func, instance._settings, config_prefix) + + # Get optional notes and examples + notes = _get_settings_value(instance._settings, config_prefix, "notes") + examples = _get_settings_value(instance._settings, config_prefix, "examples") + + # Render the docstring using the function name as template + template_name = func.__name__ + rendered_docstring = docstring_system.render_docstring( + template_name, + docstring_content=docstring_content, + parameters=parameters, + returns=returns, + notes=notes, + examples=examples, + ) + + wrapper.__doc__ = rendered_docstring # Store the update function for later use wrapper._update_docstring = update_docstring wrapper._original_func = func return wrapper + return decorator @@ -181,5 +233,5 @@ def setup_extensible_docstrings(instance, docstring_system: DocstringTemplateSys # Find all methods with extensible docstrings and update them for attr_name in dir(instance): attr = getattr(instance, attr_name) - if hasattr(attr, '_update_docstring'): + if hasattr(attr, "_update_docstring"): attr._update_docstring(instance) diff --git a/services/mcp-server/src/rag_mcp_server.py b/services/mcp-server/src/rag_mcp_server.py index fc6df3a5..ff552d30 100644 --- a/services/mcp-server/src/rag_mcp_server.py +++ b/services/mcp-server/src/rag_mcp_server.py @@ -25,11 +25,11 @@ def __init__(self, api_client: RagApi, mcp_server: FastMCP, settings: BaseSettin self._api_client = api_client self._server = mcp_server self._settings = settings - + # Initialize the docstring system docstring_system = DocstringTemplateSystem(settings) setup_extensible_docstrings(self, docstring_system) - + self._register_tools() def run(self): diff --git a/services/mcp-server/src/settings/mcp_settings.py b/services/mcp-server/src/settings/mcp_settings.py index e26d349d..08eec17d 100644 --- a/services/mcp-server/src/settings/mcp_settings.py +++ b/services/mcp-server/src/settings/mcp_settings.py @@ -2,7 +2,7 @@ from pydantic import Field from pydantic_settings import BaseSettings -from typing import Dict + class MCPSettings(BaseSettings): """ @@ -34,33 +34,47 @@ class Config: # Chat Simple Method Configuration chat_simple_description: str = Field( - default="Send a message to the RAG system and get a simple text response.\n\nThis is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." + default=( + "Send a message to the RAG system and get a simple text response.\n\n" + "This is the simplest way to interact with the RAG system - just provide a message " + "and get back the answer as plain text." + ) ) - chat_simple_parameter_descriptions: Dict[str, str] = Field( + chat_simple_parameter_descriptions: dict[str, str] = Field( default_factory=lambda: { "session_id": "Unique identifier for the chat session.", - "message": "The message/question to ask the RAG system." + "message": "The message/question to ask the RAG system.", } ) - chat_simple_returns: str = Field( - default="The answer from the RAG system as plain text." - ) + chat_simple_returns: str = Field(default="The answer from the RAG system as plain text.") chat_simple_notes: str = Field(default="") chat_simple_examples: str = Field(default="") # Chat With History Method Configuration chat_with_history_description: str = Field( - default="Send a message with conversation history and get structured response.\n\nProvide conversation history as a simple list of dictionaries.\nEach history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + default=( + "Send a message with conversation history and get structured response.\n\n" + "Provide conversation history as a simple list of dictionaries.\n" + "Each history item should have 'role' (either 'user' or 'assistant') and 'message' keys." + ) ) - chat_with_history_parameter_descriptions: Dict[str, str] = Field( + chat_with_history_parameter_descriptions: dict[str, str] = Field( default_factory=lambda: { "session_id": "Unique identifier for the chat session.", "message": "The current message/question to ask.", - "history": "Previous conversation history. Each item should be:\n {\"role\": \"user\" or \"assistant\", \"message\": \"the message text\"}" + "history": ( + "Previous conversation history. Each item should be:\n" + ' {"role": "user" or "assistant", "message": "the message text"}' + ), } ) chat_with_history_returns: str = Field( - default="Response containing:\n - answer: The response text\n - finish_reason: Why the response ended\n - citations: List of source documents used (simplified)" + default=( + "Response containing:\n" + " - answer: The response text\n" + " - finish_reason: Why the response ended\n" + " - citations: List of source documents used (simplified)" + ) ) chat_with_history_notes: str = Field(default="") chat_with_history_examples: str = Field(default="") diff --git a/services/mcp-server/tests/docstring_system_test.py b/services/mcp-server/tests/docstring_system_test.py index ff83247f..7e5530fb 100644 --- a/services/mcp-server/tests/docstring_system_test.py +++ b/services/mcp-server/tests/docstring_system_test.py @@ -20,6 +20,7 @@ def docstring_system(settings): @pytest.fixture def test_class_factory(): """Factory fixture for creating test classes.""" + def _create_test_class(settings): class TestClass: def __init__(self, settings): @@ -36,6 +37,7 @@ def chat_with_history(self, session_id: str, message: str, history: list = None) return {"answer": f"Response for {session_id}: {message}", "citations": []} return TestClass(settings) + return _create_test_class @@ -49,22 +51,22 @@ def test_docstring_system_initialization(settings, docstring_system): def test_get_template(docstring_system): """Test template retrieval.""" - template = docstring_system.get_template('chat_simple') + template = docstring_system.get_template("chat_simple") assert template is not None - template = docstring_system.get_template('chat_with_history') + template = docstring_system.get_template("chat_with_history") assert template is not None def test_render_docstring_basic(docstring_system): """Test basic docstring rendering.""" result = docstring_system.render_docstring( - 'chat_simple', + "chat_simple", docstring_content="Test description", parameters=[], returns={"type": "str", "description": "Test return"}, notes="", - examples="" + examples="", ) assert "Test description" in result @@ -78,16 +80,16 @@ def test_render_docstring_with_parameters(docstring_system): """Test docstring rendering with parameters.""" parameters = [ {"name": "param1", "type": "str", "description": "First parameter"}, - {"name": "param2", "type": "int", "description": "Second parameter"} + {"name": "param2", "type": "int", "description": "Second parameter"}, ] result = docstring_system.render_docstring( - 'chat_simple', + "chat_simple", docstring_content="Test description", parameters=parameters, returns={"type": "str", "description": "Test return"}, notes="", - examples="" + examples="", ) assert "param1: str" in result @@ -99,12 +101,12 @@ def test_render_docstring_with_parameters(docstring_system): def test_render_docstring_with_notes_and_examples(docstring_system): """Test docstring rendering with notes and examples.""" result = docstring_system.render_docstring( - 'chat_simple', + "chat_simple", docstring_content="Test description", parameters=[], returns={"type": "str", "description": "Test return"}, notes="This is a test note", - examples=">>> example_function()\n'result'" + examples=">>> example_function()\n'result'", ) assert "Notes" in result @@ -116,12 +118,12 @@ def test_render_docstring_with_notes_and_examples(docstring_system): def test_render_docstring_without_notes_and_examples(docstring_system): """Test docstring rendering without notes and examples.""" result = docstring_system.render_docstring( - 'chat_simple', + "chat_simple", docstring_content="Test description", parameters=[], returns={"type": "str", "description": "Test return"}, notes="", - examples="" + examples="", ) assert "Notes" not in result @@ -140,12 +142,13 @@ def test_function(): pass assert test_function is not None - assert hasattr(test_function, '_update_docstring') - assert hasattr(test_function, '_original_func') + assert hasattr(test_function, "_update_docstring") + assert hasattr(test_function, "_original_func") def test_decorator_preserves_function_metadata(): """Test that the decorator preserves function metadata.""" + @extensible_docstring("chat_simple") def test_function(param1: str, param2: int) -> str: """Original docstring.""" @@ -157,6 +160,7 @@ def test_function(param1: str, param2: int) -> str: def test_decorator_function_execution(): """Test that decorated functions still execute correctly.""" + @extensible_docstring("chat_simple") def test_function(x: int) -> int: return x * 2 @@ -169,7 +173,7 @@ def test_function(x: int) -> int: def test_default_settings(settings): """Test that default settings are loaded correctly.""" # Test basic settings - assert settings.host == "0.0.0.0" + assert settings.host == "0.0.0.0" # nosec S104 - test setting only assert settings.port == 8000 assert settings.name == "RAG MCP server" @@ -190,13 +194,9 @@ def test_default_settings(settings): def test_parameter_descriptions(): """Test parameter descriptions configuration.""" - param_descriptions = { - "test_param": "Test description for parameter" - } + param_descriptions = {"test_param": "Test description for parameter"} - settings = MCPSettings( - chat_simple_parameter_descriptions=param_descriptions - ) + settings = MCPSettings(chat_simple_parameter_descriptions=param_descriptions) assert settings.chat_simple_parameter_descriptions == param_descriptions @@ -209,9 +209,7 @@ def test_return_config(): def test_custom_settings(): """Test custom settings override.""" - custom_param_descriptions = { - "custom_param": "Custom parameter description" - } + custom_param_descriptions = {"custom_param": "Custom parameter description"} custom_returns = "Custom return description" settings = MCPSettings( @@ -219,7 +217,7 @@ def test_custom_settings(): chat_simple_parameter_descriptions=custom_param_descriptions, chat_simple_returns=custom_returns, chat_simple_notes="Custom notes", - chat_simple_examples="Custom examples" + chat_simple_examples="Custom examples", ) assert settings.chat_simple_description == "Custom description" @@ -235,7 +233,7 @@ def test_setup_extensible_docstrings(settings, test_class_factory): instance = test_class_factory(settings) # Check that the docstring system is set up - assert hasattr(instance, '__docstring_system') + assert hasattr(instance, "__docstring_system") # Check that docstrings are generated assert instance.chat_simple.__doc__ is not None @@ -273,11 +271,11 @@ def test_custom_configuration(test_class_factory): chat_simple_description="Custom simple description", chat_simple_parameter_descriptions={ "session_id": "Custom session parameter", - "message": "Custom message parameter" + "message": "Custom message parameter", }, chat_simple_returns="Custom return value", chat_simple_notes="Custom notes section", - chat_simple_examples=">>> custom_example()\n'result'" + chat_simple_examples=">>> custom_example()\n'result'", ) instance = test_class_factory(custom_settings) @@ -318,8 +316,8 @@ def test_missing_settings_attributes(): settings = MCPSettings() # Remove some attributes to test handling - delattr(settings, 'chat_simple_notes') - delattr(settings, 'chat_simple_examples') + delattr(settings, "chat_simple_notes") + delattr(settings, "chat_simple_examples") class TestClass: def __init__(self, settings): @@ -339,10 +337,7 @@ def test_method(self): def test_empty_configuration(): """Test behavior with empty configuration.""" settings = MCPSettings( - chat_simple_description="", - chat_simple_parameter_descriptions={}, - chat_simple_notes="", - chat_simple_examples="" + chat_simple_description="", chat_simple_parameter_descriptions={}, chat_simple_notes="", chat_simple_examples="" ) class TestClass: From 79d4136a387ce1cff502612f3bb8ab79fa6be4d3 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 12:48:46 +0200 Subject: [PATCH 6/8] refactor: remove unused template retrieval test from DocstringTemplateSystem tests --- services/mcp-server/src/docstring_system.py | 1 - services/mcp-server/tests/docstring_system_test.py | 9 --------- 2 files changed, 10 deletions(-) diff --git a/services/mcp-server/src/docstring_system.py b/services/mcp-server/src/docstring_system.py index 57d09757..6f62175d 100644 --- a/services/mcp-server/src/docstring_system.py +++ b/services/mcp-server/src/docstring_system.py @@ -20,7 +20,6 @@ def __init__(self, settings: BaseSettings): Settings object containing template configurations """ self.settings = settings - self.templates = {} self._setup_templates() def render_docstring(self, template_name: str, **kwargs) -> str: diff --git a/services/mcp-server/tests/docstring_system_test.py b/services/mcp-server/tests/docstring_system_test.py index 7e5530fb..ceacdc19 100644 --- a/services/mcp-server/tests/docstring_system_test.py +++ b/services/mcp-server/tests/docstring_system_test.py @@ -49,15 +49,6 @@ def test_docstring_system_initialization(settings, docstring_system): assert docstring_system.settings == settings -def test_get_template(docstring_system): - """Test template retrieval.""" - template = docstring_system.get_template("chat_simple") - assert template is not None - - template = docstring_system.get_template("chat_with_history") - assert template is not None - - def test_render_docstring_basic(docstring_system): """Test basic docstring rendering.""" result = docstring_system.render_docstring( From e30360ab42f9ea8062b0a13d4b0cba8bac592507 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 17 Jul 2025 12:53:48 +0200 Subject: [PATCH 7/8] fix: update assertion for rendered docstring examples to escape HTML characters --- services/mcp-server/tests/docstring_system_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/mcp-server/tests/docstring_system_test.py b/services/mcp-server/tests/docstring_system_test.py index ceacdc19..f678b463 100644 --- a/services/mcp-server/tests/docstring_system_test.py +++ b/services/mcp-server/tests/docstring_system_test.py @@ -103,7 +103,7 @@ def test_render_docstring_with_notes_and_examples(docstring_system): assert "Notes" in result assert "This is a test note" in result assert "Examples" in result - assert ">>> example_function()" in result + assert ">>> example_function()" in result def test_render_docstring_without_notes_and_examples(docstring_system): @@ -283,7 +283,7 @@ def test_custom_configuration(test_class_factory): assert "Notes" in simple_doc assert "Custom notes section" in simple_doc assert "Examples" in simple_doc - assert ">>> custom_example()" in simple_doc + assert ">>> custom_example()" in simple_doc def test_function_execution_still_works(settings, test_class_factory): From eb4fcc9ddee88fd066b787d9b935f97a043db73c Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Fri, 18 Jul 2025 13:35:10 +0200 Subject: [PATCH 8/8] feat: enhance docstring rendering with detailed parameter and return descriptions --- infrastructure/rag/values.yaml | 27 +++++++++++++++++++++ services/mcp-server/src/docstring_system.py | 17 +++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/infrastructure/rag/values.yaml b/infrastructure/rag/values.yaml index 3e97c2f6..09938e00 100644 --- a/infrastructure/rag/values.yaml +++ b/infrastructure/rag/values.yaml @@ -22,13 +22,40 @@ backend: host: "0.0.0.0" # Chat simple tool configuration + # The following configuration for the chat_simple tool will render as follows: + # """Send a message to the RAG system and get a simple text response. + + # This is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text. + # + # Parameters + # ---------- + # session_id : str + # Unique identifier for the chat session. + # message : str + # The message/question to ask the RAG system. + # + # Returns + # ------- + # str + # The answer from the RAG system as plain text. + # """ chatSimpleDescription: "Send a message to the RAG system and get a simple text response.\n\nThis is the simplest way to interact with the RAG system - just provide a message and get back the answer as plain text." chatSimpleParameterDescriptions: session_id: "Unique identifier for the chat session." message: "The message/question to ask the RAG system." chatSimpleReturns: "The answer from the RAG system as plain text." chatSimpleNotes: "" + # If you add a Value to chatSimpleNotes e.g. "This tool is best for simple questions that don't require conversation context." + # it will render to: + # Notes + # ----- + # This tool is best for simple questions that don't require conversation context. chatSimpleExamples: "" + # If you add a Value to chatSimpleExamples e.g. "chat_simple(session_id='my-session', message='What is the main topic of the document?')" + # it will render to: + # Examples + # -------- + # chat_simple(session_id='my-session', message='What is the main topic of the document?') # Chat with history tool configuration chatWithHistoryDescription: "Send a message with conversation history and get structured response.\n\nProvide conversation history as a simple list of dictionaries.\nEach history item should have 'role' (either 'user' or 'assistant') and 'message' keys." diff --git a/services/mcp-server/src/docstring_system.py b/services/mcp-server/src/docstring_system.py index 6f62175d..e2081033 100644 --- a/services/mcp-server/src/docstring_system.py +++ b/services/mcp-server/src/docstring_system.py @@ -22,8 +22,21 @@ def __init__(self, settings: BaseSettings): self.settings = settings self._setup_templates() - def render_docstring(self, template_name: str, **kwargs) -> str: - """Render a docstring using the template.""" + def render_docstring(self, template_name: str, **kwargs: dict) -> str: + """Render a docstring using the template. + + Parameters + ---------- + template_name : str + Name of the template to render + **kwargs : dict + Keyword arguments to pass to the render function of the template + + Returns + ------- + str + Rendered docstring + """ return self.template.render(**kwargs) def _setup_templates(self):