From e98d46adcdb97fe77f698f07fd96697475ceb50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Thu, 29 Jan 2026 08:00:05 +0100 Subject: [PATCH 01/36] wip: query_ai --- guillotina/contrib/ai_query/README.md | 176 ++++++++++++++ guillotina/contrib/ai_query/__init__.py | 30 +++ guillotina/contrib/ai_query/handler.py | 201 ++++++++++++++++ guillotina/contrib/ai_query/prompts.py | 172 ++++++++++++++ guillotina/contrib/ai_query/providers.py | 152 ++++++++++++ .../contrib/ai_query/result_processor.py | 108 +++++++++ .../contrib/ai_query/schema_analyzer.py | 95 ++++++++ guillotina/contrib/ai_query/services.py | 219 ++++++++++++++++++ guillotina/tests/test_ai_query.py | 86 +++++++ 9 files changed, 1239 insertions(+) create mode 100644 guillotina/contrib/ai_query/README.md create mode 100644 guillotina/contrib/ai_query/__init__.py create mode 100644 guillotina/contrib/ai_query/handler.py create mode 100644 guillotina/contrib/ai_query/prompts.py create mode 100644 guillotina/contrib/ai_query/providers.py create mode 100644 guillotina/contrib/ai_query/result_processor.py create mode 100644 guillotina/contrib/ai_query/schema_analyzer.py create mode 100644 guillotina/contrib/ai_query/services.py create mode 100644 guillotina/tests/test_ai_query.py diff --git a/guillotina/contrib/ai_query/README.md b/guillotina/contrib/ai_query/README.md new file mode 100644 index 000000000..9bff553ab --- /dev/null +++ b/guillotina/contrib/ai_query/README.md @@ -0,0 +1,176 @@ +# ai_query + +Contrib to query indexed catalog content in Guillotina using natural language. It translates the question into a structured query, runs the search, and optionally generates a text response. + +## Requirements + +- Guillotina with catalog (e.g. `guillotina.contrib.catalog.pg`) +- **LiteLLM**: `pip install litellm` +- An API key from your chosen LLM provider (OpenAI, Anthropic, Google/Gemini, etc.) + +## Installation + +1. Add the application to your project: + +```yaml +# config.yaml (or your configuration) +applications: + - guillotina.contrib.catalog.pg + - guillotina.contrib.ai_query +``` + +2. Install litellm if you don't have it: + +```bash +pip install litellm +``` + +## Configuration + +Default configuration (you can override in `config.yaml` or environment variables): + +```yaml +ai_query: + enabled: true + provider: openai + model: gpt-4o-mini + api_key: null + base_url: null + max_tokens: 500 + temperature: 0.1 + response_temperature: 0.7 + timeout: 30 + enable_conversation: true + max_conversation_history: 10 + litellm_settings: + retry: + attempts: 3 +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `enabled` | Enable or disable the contrib | `true` | +| `provider` | LiteLLM provider: `openai`, `anthropic`, `azure`, `gemini`, `google`, `ollama` | `openai` | +| `model` | Model name (e.g. `gpt-4o-mini`, `gemini-pro`) | `gpt-4o-mini` | +| `api_key` | API key (optional if set via environment) | `null` | +| `base_url` | Base URL for the API (e.g. proxy or custom endpoint) | `null` | +| `max_tokens` | Maximum tokens per response | `500` | +| `temperature` | Temperature for query translation (0–1) | `0.1` | +| `response_temperature` | Temperature for text response (0–1) | `0.7` | +| `timeout` | Timeout in seconds for LLM calls | `30` | +| `enable_conversation` | Allow sending conversation history in the request body | `true` | +| `max_conversation_history` | Maximum number of history messages considered | `10` | +| `litellm_settings.retry.attempts` | Retries on network/rate limit errors | `3` | + +### API key via environment variable + +If you don't set `api_key` in config, it is read from the environment based on `provider`: + +| provider | Environment variable | +|----------|---------------------| +| openai | `OPENAI_API_KEY` | +| anthropic | `ANTHROPIC_API_KEY` | +| azure | `AZURE_API_KEY` | +| gemini / google | `GEMINI_API_KEY` | +| ollama | (not required) | + +Example with Gemini: + +```yaml +ai_query: + provider: gemini + model: gemini-pro +``` + +And in the environment: `export GEMINI_API_KEY=...` + +## Permissions + +- **Permission:** `guillotina.ai_query.Query` +- **Default:** granted to role `guillotina.Authenticated` + +To restrict who can run queries, change the grants in your `config.yaml` or initialization code. + +## API + +### Endpoint + +`POST /{path_to_container}/@ai-query` + +Example: `POST /db/my-db/@ai-query` + +### Request body (JSON) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | string | Yes | Natural language question | +| `response_format` | string | No | `natural` (text) or `structured` (search data only). Default: `natural` | +| `stream` | boolean | No | If `true`, response is Server-Sent Events (SSE). Default: `false` | +| `conversation_id` | string | No | Conversation identifier (for context) | +| `context` | array | No | Message history `[{ "role": "user"|"assistant", "content": "..." }]` | + +### Response without stream (`stream: false`) + +- **response_format: natural** + +```json +{ + "answer": "Text generated by the LLM from the results.", + "data": { "items": [...], "items_total": N }, + "conversation_id": "uuid" +} +``` + +- **response_format: structured** + +Returns the search results object directly (no `answer` or `conversation_id`). + +### Response with stream (`stream: true`) + +`Content-Type: text/event-stream` + +SSE events: + +- `data: \n\n` events with chunks of the response. +- A final `event: done` with `data: {"data": {...}, "conversation_id": "..."}` (JSON). +- On stream error: `event: error` with `data: {"error": "..."}`. + +### Example (without stream) + +```bash +curl -X POST "http://localhost:8080/db/container/@ai-query" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query": "How many documents are there?"}' +``` + +### Example (with stream) + +```bash +curl -X POST "http://localhost:8080/db/container/@ai-query" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query": "How many documents are there?", "stream": true}' +``` + +### Example with conversation + +```json +{ + "query": "And the Folder type?", + "conversation_id": "conv-123", + "context": [ + { "role": "user", "content": "How many items are there?" }, + { "role": "assistant", "content": "There are 42 items." } + ] +} +``` + +## How it works + +1. The catalog schema (content types and indexed fields) is discovered for the container where `@ai-query` is called. +2. The natural language question is translated into a JSON query (type, filters, `_metadata`, `_size`, etc.) via the LLM. +3. The query is executed against Guillotina's catalog. +4. If a natural language response was requested, results are passed to the LLM to generate the text (or streamed when `stream: true`). + +Aggregations (sum, count, average) are computed in memory over the search results; Guillotina's catalog does not receive an aggregation parameter. diff --git a/guillotina/contrib/ai_query/__init__.py b/guillotina/contrib/ai_query/__init__.py new file mode 100644 index 000000000..151d06ec4 --- /dev/null +++ b/guillotina/contrib/ai_query/__init__.py @@ -0,0 +1,30 @@ +from guillotina import configure + + +configure.permission("guillotina.ai_query.Query", "Query data using natural language") +configure.grant(permission="guillotina.ai_query.Query", role="guillotina.Authenticated") + + +app_settings = { + "ai_query": { + "enabled": True, + "provider": "openai", + "model": "gpt-4o-mini", + "api_key": None, + "base_url": None, + "max_tokens": 500, + "query_translation_max_tokens": 1024, + "temperature": 0.1, + "response_temperature": 0.7, + "timeout": 30, + "enable_conversation": True, + "max_conversation_history": 10, + "litellm_settings": { + "retry": {"attempts": 3}, + }, + } +} + + +def includeme(root, settings): + configure.scan("guillotina.contrib.ai_query.services") diff --git a/guillotina/contrib/ai_query/handler.py b/guillotina/contrib/ai_query/handler.py new file mode 100644 index 000000000..d0a4f1a9c --- /dev/null +++ b/guillotina/contrib/ai_query/handler.py @@ -0,0 +1,201 @@ +from guillotina import app_settings +from guillotina.contrib.ai_query.prompts import PromptBuilder +from guillotina.contrib.ai_query.providers import LLMProvider +from guillotina.contrib.ai_query.schema_analyzer import SchemaAnalyzer +from guillotina.interfaces import IResource +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Optional + +import json +import logging + + +logger = logging.getLogger("guillotina") + + +def _looks_truncated(text: str) -> bool: + """Heuristic: response likely cut off before completing JSON.""" + if not text or len(text) < 10: + return True + stripped = text.strip() + if stripped.endswith(",") or stripped.endswith(":") or stripped.endswith('"'): + return True + open_braces = stripped.count("{") - stripped.count("}") + open_brackets = stripped.count("[") - stripped.count("]") + return open_braces > 0 or open_brackets > 0 + + +class AIQueryHandler: + """ + Handler for translating natural language queries and generating responses. + """ + + def __init__(self): + self.provider = LLMProvider() + + @property + def settings(self): + return app_settings.get("ai_query", {}) + + async def get_schema_info(self, context: IResource) -> dict: + """Get schema information for the context.""" + analyzer = SchemaAnalyzer(context) + return await analyzer.get_schema_info() + + async def translate_query( + self, + natural_language: str, + context: IResource, + schema_info: dict, + conversation_history: Optional[List[Dict]] = None, + ) -> dict: + """ + Translate natural language query to structured query format. + """ + if not self.provider.is_enabled(): + raise ValueError("AI query is not enabled") + + system_prompt, user_prompt_template = PromptBuilder.build_query_translation_prompt( + schema_info, conversation_history + ) + user_prompt = user_prompt_template.format(query=natural_language) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + temperature = self.settings.get("temperature", 0.1) + max_tokens = self.settings.get( + "query_translation_max_tokens", self.settings.get("max_tokens", 1024) + ) + timeout = self.settings.get("timeout", 30) + + try: + response_text = await self.provider.completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + query = self._parse_query_response(response_text) + self._validate_query(query, schema_info) + + return query + except Exception as e: + logger.error(f"Query translation failed: {e}", exc_info=True) + raise + + async def generate_response( + self, + query: str, + results: dict, + schema_info: dict, + conversation_history: Optional[List[Dict]] = None, + ) -> str: + """ + Generate natural language response from query results. + """ + if not self.provider.is_enabled(): + return "AI query is not enabled" + + messages = PromptBuilder.build_response_generation_prompt( + query, results, schema_info, conversation_history + ) + + temperature = self.settings.get("response_temperature", 0.7) + max_tokens = self.settings.get("max_tokens", 500) + timeout = self.settings.get("timeout", 30) + + try: + response = await self.provider.completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + return response + except Exception as e: + logger.error(f"Response generation failed: {e}", exc_info=True) + return f"Error generating response: {str(e)}" + + async def generate_response_stream( + self, + query: str, + results: dict, + schema_info: dict, + conversation_history: Optional[List[Dict]] = None, + ) -> AsyncGenerator[str, None]: + """ + Generate natural language response from query results; yields content chunks. + """ + if not self.provider.is_enabled(): + yield "AI query is not enabled" + return + + messages = PromptBuilder.build_response_generation_prompt( + query, results, schema_info, conversation_history + ) + + temperature = self.settings.get("response_temperature", 0.7) + max_tokens = self.settings.get("max_tokens", 500) + timeout = self.settings.get("timeout", 30) + + try: + async for chunk in self.provider.completion_stream( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ): + yield chunk + except Exception as e: + logger.error(f"Response stream failed: {e}", exc_info=True) + yield f"Error generating response: {str(e)}" + + def _parse_query_response(self, response_text: str) -> dict: + """Parse LLM response text into query dict.""" + response_text = response_text.strip() + + if response_text.startswith("```json"): + response_text = response_text[7:] + if response_text.startswith("```"): + response_text = response_text[3:] + if response_text.endswith("```"): + response_text = response_text[:-3] + response_text = response_text.strip() + + try: + return json.loads(response_text) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse query response: {response_text}", exc_info=True) + if _looks_truncated(response_text): + raise ValueError( + "LLM response was truncated (incomplete JSON). " + "Increase ai_query.query_translation_max_tokens in settings." + ) from e + raise ValueError(f"Invalid query format: {e}") from e + + def _validate_query(self, query: dict, schema_info: dict): + """Validate translated query against discovered schema.""" + if not isinstance(query, dict): + raise ValueError("Query must be a dictionary") + + type_name = query.get("type_name") + if type_name: + if type_name not in schema_info.get("content_types", {}): + raise ValueError(f"Unknown content type: {type_name}") + + content_types = schema_info.get("content_types", {}) + for key, value in query.items(): + if key.startswith("_") or key == "type_name" or key == "aggregation": + continue + + field_name = key.split("__")[0] + if type_name and type_name in content_types: + if field_name not in content_types[type_name]: + logger.warning( + f"Field {field_name} not found in {type_name}, but allowing query" + ) diff --git a/guillotina/contrib/ai_query/prompts.py b/guillotina/contrib/ai_query/prompts.py new file mode 100644 index 000000000..9cc8d666d --- /dev/null +++ b/guillotina/contrib/ai_query/prompts.py @@ -0,0 +1,172 @@ +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + + +class PromptBuilder: + @staticmethod + def build_query_translation_prompt( + schema_info: Dict, + conversation_history: Optional[List[Dict]] = None, + ) -> Tuple[str, str]: + """ + Build prompt for translating natural language to structured query. + """ + content_types_desc = PromptBuilder._format_content_types(schema_info) + field_types_desc = PromptBuilder._format_field_types(schema_info) + + system_prompt = """You are a query translation assistant for Guillotina, a content management system. +Your task is to translate natural language queries into structured JSON queries that match Guillotina's search syntax. + +Available content types and their indexed fields: +{content_types} + +Field type categories: +{field_types} + +Query syntax rules: +- Use `type_name` to filter by content type (e.g., "type_name": "Document") +- Field filters use format: `field_name__operator` (e.g., "title__in": "search term") +- Available operators: + - `__eq`: exact match + - `__in`: contains (for text fields) + - `__not`: does not contain + - `__gt`, `__gte`, `__lt`, `__lte`: comparisons (for numeric/date fields) + - `__wildcard`: wildcard pattern matching +- Use `_metadata` to specify which fields to return (comma-separated) +- Use `_size` to limit results (max 50) +- Use `_from` for pagination +- Use `_sort_asc` or `_sort_des` for sorting + +Date handling: +- Relative dates: "aquesta setmana" = current week, "aquest mes" = current month +- Convert to ISO format: "YYYY-MM-DDTHH:MM:SSZ" + +Return ONLY valid JSON in this format: +{{ + "type_name": "ContentTypeName", + "field_name__operator": "value", + "_metadata": "field1,field2", + "_size": 20 +}} + +If the query asks for aggregations (sum, count, average), include an "aggregation" field. +This is processed by the AI query layer in memory over search results (not by Guillotina's catalog): +{{ + "type_name": "ContentTypeName", + "field_name__operator": "value", + "_metadata": "field1,field2", + "aggregation": {{ + "operation": "sum|count|average", + "field": "numeric_field_name", + "group_by": "optional_grouping_field" + }} +}} +""" + + user_prompt_template = """Translate the following natural language query to a structured JSON query. +Use the discovered schema to map natural language terms to actual field names. +If the query references previous context, use the conversation history to understand what was asked before. + +Query: {query} + +Return only the JSON query, no additional text.""" + + if conversation_history: + history_text = "\n".join( + [ + f"{msg['role']}: {msg['content']}" + for msg in conversation_history[-5:] + ] + ) + user_prompt_template = f"""Previous conversation: +{history_text} + +{user_prompt_template}""" + + system_prompt_formatted = system_prompt.format( + content_types=content_types_desc, + field_types=field_types_desc, + ) + + return system_prompt_formatted, user_prompt_template + + @staticmethod + def build_response_generation_prompt( + query: str, + results: Dict, + schema_info: Dict, + conversation_history: Optional[List[Dict]] = None, + ) -> List[Dict]: + """ + Build messages for generating natural language response from results. + """ + system_prompt = """You are a helpful assistant that answers questions about data in Guillotina. +You receive query results and must provide a clear, natural language response in the same language as the query. + +Guidelines: +- Be concise but informative +- Use the actual field names and values from the results +- If aggregations were performed, explain the calculations +- If no results found, explain why +- Maintain conversation context if provided +- Use the same language as the user's query (Catalan, Spanish, English, etc.) +""" + + results_summary = PromptBuilder._format_results(results) + + user_content = f"""Query: {query} + +Results: +{results_summary} + +Provide a natural language answer based on these results.""" + + messages = [{"role": "system", "content": system_prompt}] + + if conversation_history: + for msg in conversation_history[-3:]: + messages.append(msg) + + messages.append({"role": "user", "content": user_content}) + + return messages + + @staticmethod + def _format_content_types(schema_info: Dict) -> str: + """Format content types and fields for prompt.""" + lines = [] + for type_name, fields in schema_info.get("content_types", {}).items(): + field_list = ", ".join([f"{k} ({v})" for k, v in fields.items()]) + lines.append(f"- {type_name}: {field_list}") + return "\n".join(lines) if lines else "No content types found" + + @staticmethod + def _format_field_types(schema_info: Dict) -> str: + """Format field type categories for prompt.""" + field_types = schema_info.get("field_types", {}) + lines = [] + for category, fields in field_types.items(): + if fields: + lines.append(f"- {category}: {', '.join(fields[:10])}") + return "\n".join(lines) if lines else "No field types found" + + @staticmethod + def _format_results(results: Dict) -> str: + """Format query results for response generation.""" + if "items" not in results: + return "No results found" + + items = results.get("items", []) + total = results.get("items_total", len(items)) + + if total == 0: + return "No results found matching the query." + + if total <= 5: + items_text = "\n".join([f"- {item}" for item in items]) + return f"Found {total} result(s):\n{items_text}" + else: + sample = "\n".join([f"- {item}" for item in items[:5]]) + return f"Found {total} result(s). Showing first 5:\n{sample}\n... and {total - 5} more" diff --git a/guillotina/contrib/ai_query/providers.py b/guillotina/contrib/ai_query/providers.py new file mode 100644 index 000000000..e45593aa0 --- /dev/null +++ b/guillotina/contrib/ai_query/providers.py @@ -0,0 +1,152 @@ +from guillotina import app_settings +from typing import Dict +from typing import List +from typing import Optional + +import logging +import os + + +logger = logging.getLogger("guillotina") + + +class LLMProvider: + """ + Wrapper around LiteLLM for Guillotina-specific needs. + Handles configuration, error handling, and provider management. + """ + + def __init__(self): + self._check_litellm_available() + + @property + def settings(self): + return app_settings.get("ai_query", {}) + + def _check_litellm_available(self): + """Check if LiteLLM is available, raise helpful error if not.""" + try: + import litellm + self.litellm = litellm + except ImportError: + raise ImportError( + "LiteLLM is required for ai_query. Install it with: pip install litellm" + ) + + def get_model_name(self) -> str: + """Get the full model name in LiteLLM format.""" + provider = self.settings.get("provider", "openai") + model = self.settings.get("model", "gpt-4o-mini") + + if "/" in model: + return model + return f"{provider}/{model}" + + def get_api_key(self) -> Optional[str]: + """Get API key from settings or environment.""" + api_key = self.settings.get("api_key") + if api_key: + return api_key + + provider = self.settings.get("provider", "openai") + + env_var_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "azure": "AZURE_API_KEY", + "gemini": "GEMINI_API_KEY", + "google": "GEMINI_API_KEY", + "ollama": None, + } + + env_var = env_var_map.get(provider) + if env_var: + return os.getenv(env_var) + + return None + + def _build_completion_kwargs( + self, + messages: List[Dict], + temperature: float = 0.1, + max_tokens: int = 500, + timeout: int = 30, + stream: bool = False, + ) -> dict: + """Build kwargs for LiteLLM acompletion.""" + kwargs = { + "model": self.get_model_name(), + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "timeout": timeout, + "stream": stream, + } + api_key = self.get_api_key() + if api_key: + kwargs["api_key"] = api_key + base_url = self.settings.get("base_url") + if base_url: + kwargs["api_base"] = base_url + retry_config = self.settings.get("litellm_settings", {}).get("retry", {}) + if retry_config.get("attempts", 0) > 0: + kwargs["num_retries"] = retry_config["attempts"] + return kwargs + + def _handle_completion_error(self, e: Exception) -> None: + """Map LLM errors to user-facing ValueError.""" + error_msg = str(e).lower() + if "rate limit" in error_msg or "429" in error_msg: + logger.error(f"LLM rate limit error: {e}") + raise ValueError("Rate limit exceeded. Please try again later.") + if "timeout" in error_msg or "timed out" in error_msg: + logger.error(f"LLM timeout error: {e}") + raise ValueError("Request timed out. Please try again.") + logger.error(f"LLM provider error: {e}", exc_info=True) + raise ValueError(f"LLM provider error: {str(e)}") + + async def completion( + self, + messages: List[Dict], + temperature: float = 0.1, + max_tokens: int = 500, + timeout: int = 30, + ) -> str: + """Call LLM provider using LiteLLM unified interface.""" + kwargs = self._build_completion_kwargs( + messages, temperature, max_tokens, timeout, stream=False + ) + try: + response = await self.litellm.acompletion(**kwargs) + if not response or not response.choices: + raise ValueError("Empty response from LLM provider") + return response.choices[0].message.content + except Exception as e: + self._handle_completion_error(e) + + async def completion_stream( + self, + messages: List[Dict], + temperature: float = 0.1, + max_tokens: int = 500, + timeout: int = 30, + ): + """Call LLM provider with stream=True; yields content chunks (str).""" + kwargs = self._build_completion_kwargs( + messages, temperature, max_tokens, timeout, stream=True + ) + try: + stream = await self.litellm.acompletion(**kwargs) + if stream is None: + raise ValueError("Empty stream from LLM provider") + async for chunk in stream: + if chunk.choices and len(chunk.choices) > 0: + delta = chunk.choices[0].delta + if hasattr(delta, "content") and delta.content: + yield delta.content + except Exception as e: + self._handle_completion_error(e) + + def is_enabled(self) -> bool: + """Check if AI query is enabled.""" + return self.settings.get("enabled", True) diff --git a/guillotina/contrib/ai_query/result_processor.py b/guillotina/contrib/ai_query/result_processor.py new file mode 100644 index 000000000..227f55867 --- /dev/null +++ b/guillotina/contrib/ai_query/result_processor.py @@ -0,0 +1,108 @@ +from typing import Dict +from typing import List +from typing import Optional + +import logging + + +logger = logging.getLogger("guillotina") + + +class ResultProcessor: + """ + Process and aggregate search results generically. + """ + + @staticmethod + def process_results( + results: Dict, + aggregation_config: Optional[Dict] = None, + ) -> Dict: + """ + Process search results and apply aggregations if needed. + """ + if not aggregation_config: + return results + + items = results.get("items", []) + if not items: + return results + + operation = aggregation_config.get("operation") + field = aggregation_config.get("field") + group_by = aggregation_config.get("group_by") + + if operation == "sum": + return ResultProcessor._sum_aggregation(items, field, group_by) + elif operation == "count": + return ResultProcessor._count_aggregation(items, field, group_by) + elif operation == "average": + return ResultProcessor._average_aggregation(items, field, group_by) + else: + logger.warning(f"Unknown aggregation operation: {operation}") + return results + + @staticmethod + def _sum_aggregation( + items: List[Dict], field: str, group_by: Optional[str] = None + ) -> Dict: + """Sum numeric field values, optionally grouped by another field.""" + if group_by: + grouped = {} + for item in items: + group_key = item.get(group_by, "unknown") + value = ResultProcessor._get_numeric_value(item, field) + grouped[group_key] = grouped.get(group_key, 0) + value + return {"by_" + group_by: grouped, "total": sum(grouped.values())} + else: + total = sum(ResultProcessor._get_numeric_value(item, field) for item in items) + return {"total": total, "items_count": len(items)} + + @staticmethod + def _count_aggregation( + items: List[Dict], field: Optional[str] = None, group_by: Optional[str] = None + ) -> Dict: + """Count items, optionally grouped by field.""" + if group_by: + grouped = {} + for item in items: + group_key = item.get(group_by, "unknown") + grouped[group_key] = grouped.get(group_key, 0) + 1 + return {"by_" + group_by: grouped, "total": len(items)} + else: + return {"count": len(items)} + + @staticmethod + def _average_aggregation( + items: List[Dict], field: str, group_by: Optional[str] = None + ) -> Dict: + """Calculate average of numeric field, optionally grouped.""" + if group_by: + grouped = {} + counts = {} + for item in items: + group_key = item.get(group_by, "unknown") + value = ResultProcessor._get_numeric_value(item, field) + grouped[group_key] = grouped.get(group_key, 0) + value + counts[group_key] = counts.get(group_key, 0) + 1 + + averages = { + k: v / counts[k] if counts[k] > 0 else 0 + for k, v in grouped.items() + } + return {"by_" + group_by: averages, "overall_average": sum(grouped.values()) / len(items) if items else 0} + else: + total = sum(ResultProcessor._get_numeric_value(item, field) for item in items) + return {"average": total / len(items) if items else 0, "items_count": len(items)} + + @staticmethod + def _get_numeric_value(item: Dict, field: str) -> float: + """Extract numeric value from item, handling nested fields.""" + value = item.get(field, 0) + if isinstance(value, (int, float)): + return float(value) + try: + return float(value) + except (ValueError, TypeError): + logger.warning(f"Could not convert {field} to numeric: {value}") + return 0.0 diff --git a/guillotina/contrib/ai_query/schema_analyzer.py b/guillotina/contrib/ai_query/schema_analyzer.py new file mode 100644 index 000000000..4ab1304f0 --- /dev/null +++ b/guillotina/contrib/ai_query/schema_analyzer.py @@ -0,0 +1,95 @@ +from guillotina.component import get_utilities_for +from guillotina.content import get_all_possible_schemas_for_type +from guillotina.directives import index +from guillotina.directives import merged_tagged_value_dict +from guillotina.interfaces import IBehavior +from guillotina.interfaces import IResource +from guillotina.interfaces import IResourceFactory + +import logging + + +logger = logging.getLogger("guillotina") + + +class SchemaAnalyzer: + def __init__(self, context: IResource): + self.context = context + + async def get_schema_info(self) -> dict: + """ + Discover all content types, indexed fields, and field types dynamically. + Returns a dictionary with schema information. + """ + schema_info = { + "content_types": {}, + "behaviors": {}, + "field_types": {}, + } + + factories = list(get_utilities_for(IResourceFactory)) + logger.debug(f"Discovered {len(factories)} content type factories") + + for factory_name, factory in factories: + type_name = factory.type_name + if type_name is None: + continue + + type_schema = {} + schemas = get_all_possible_schemas_for_type(type_name) + + for schema in schemas: + indices = merged_tagged_value_dict(schema, index.key) + for field_name, index_info in indices.items(): + field_type = index_info.get("type", "text") + type_schema[field_name] = field_type + + if type_schema: + schema_info["content_types"][type_name] = type_schema + + behaviors = list(get_utilities_for(IBehavior)) + logger.debug(f"Discovered {len(behaviors)} behaviors") + + for behavior_name, behavior_utility in behaviors: + behavior_schema = {} + indices = merged_tagged_value_dict(behavior_utility.interface, index.key) + for field_name, index_info in indices.items(): + field_type = index_info.get("type", "text") + behavior_schema[field_name] = field_type + + if behavior_schema: + schema_info["behaviors"][behavior_name] = behavior_schema + + schema_info["field_types"] = self._categorize_field_types(schema_info) + + return schema_info + + def _categorize_field_types(self, schema_info: dict) -> dict: + """ + Categorize fields by type for easier query building. + """ + field_types = { + "numeric": [], + "date": [], + "text": [], + "keyword": [], + } + + all_fields = {} + for type_name, fields in schema_info["content_types"].items(): + for field_name, field_type in fields.items(): + key = f"{type_name}.{field_name}" + if key not in all_fields: + all_fields[key] = field_type + + for key, field_type in all_fields.items(): + if field_type in ("int", "float", "decimal"): + field_types["numeric"].append(key) + elif field_type in ("date", "datetime"): + field_types["date"].append(key) + elif field_type == "keyword": + field_types["keyword"].append(key) + else: + field_types["text"].append(key) + + return field_types diff --git a/guillotina/contrib/ai_query/services.py b/guillotina/contrib/ai_query/services.py new file mode 100644 index 000000000..dcbcae187 --- /dev/null +++ b/guillotina/contrib/ai_query/services.py @@ -0,0 +1,219 @@ +from guillotina import configure +from guillotina._settings import app_settings +from guillotina.api.service import Service +from guillotina.component import query_utility +from guillotina.contrib.ai_query.result_processor import ResultProcessor +from guillotina.contrib.ai_query.handler import AIQueryHandler +from guillotina.interfaces import ICatalogUtility +from guillotina.interfaces import IResource +from guillotina.response import HTTPPreconditionFailed +from guillotina.response import HTTPServiceUnavailable +from guillotina.response import Response +from typing import Dict +from typing import List +from typing import Optional +from uuid import uuid4 + +import logging +import orjson + + +logger = logging.getLogger("guillotina") + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.ai_query.Query", + name="@ai-query", + summary="Query data using natural language", + requestBody={ + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Natural language query"}, + "response_format": { + "type": "string", + "enum": ["natural", "structured"], + "default": "natural", + }, + "conversation_id": { + "type": "string", + "description": "Optional conversation ID for context", + }, + "context": { + "type": "array", + "description": "Previous conversation messages", + "items": { + "type": "object", + "properties": { + "role": {"type": "string", "enum": ["user", "assistant"]}, + "content": {"type": "string"}, + }, + }, + }, + "stream": { + "type": "boolean", + "description": "Stream the answer as Server-Sent Events", + "default": False, + }, + }, + "required": ["query"], + } + } + }, + }, + responses={ + "200": { + "description": "Query results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"}, + "data": {"type": "object"}, + "conversation_id": {"type": "string"}, + }, + } + } + }, + } + }, +) +class AIQueryService(Service): + async def __call__(self): + data = await self.get_data() + + query_text = data.get("query") + if not query_text: + raise HTTPPreconditionFailed(content={"reason": "query is required"}) + + response_format = data.get("response_format", "natural") + stream = data.get("stream", False) + conversation_id = data.get("conversation_id") or str(uuid4()) + context = data.get("context", []) + + settings = app_settings.get("ai_query", {}) + if not settings.get("enabled", True): + raise HTTPServiceUnavailable(content={"reason": "AI query is not enabled"}) + + ai_query_handler = AIQueryHandler() + + try: + schema_info = await ai_query_handler.get_schema_info(self.context) + + conversation_history = self._prepare_conversation_history(context, settings) + + translated_query = await ai_query_handler.translate_query( + query_text, self.context, schema_info, conversation_history + ) + import pdb; pdb.set_trace() + search_results = await self._execute_query(translated_query) + + aggregation_config = translated_query.get("aggregation") + if aggregation_config: + processed_results = ResultProcessor.process_results( + search_results, aggregation_config + ) + else: + processed_results = search_results + import pdb; pdb.set_trace() + if response_format == "natural": + if stream: + return await self._stream_response( + ai_query_handler, + query_text, + processed_results, + schema_info, + conversation_history, + conversation_id, + ) + answer = await ai_query_handler.generate_response( + query_text, processed_results, schema_info, conversation_history + ) + return { + "answer": answer, + "data": processed_results, + "conversation_id": conversation_id, + } + else: + return processed_results + + except ValueError as e: + logger.error(f"Query validation error: {e}", exc_info=True) + raise HTTPPreconditionFailed(content={"reason": str(e)}) + except Exception as e: + logger.error(f"AI query error: {e}", exc_info=True) + raise HTTPServiceUnavailable( + content={"reason": f"Query processing failed: {str(e)}"} + ) + + async def _execute_query(self, query: dict) -> dict: + """Execute the translated query using catalog utility.""" + catalog = query_utility(ICatalogUtility) + if catalog is None: + raise HTTPServiceUnavailable(content={"reason": "Catalog utility not available"}) + + aggregation = query.pop("aggregation", None) + + try: + results = await catalog.search(self.context, query) + if aggregation: + query["aggregation"] = aggregation + return results + except Exception as e: + logger.error(f"Query execution error: {e}", exc_info=True) + raise ValueError(f"Failed to execute query: {str(e)}") + + async def _stream_response( + self, + ai_query_handler, + query_text: str, + processed_results: dict, + schema_info: dict, + conversation_history: Optional[List[Dict]], + conversation_id: str, + ) -> Response: + """Return SSE stream: event chunk with data, then event done with payload.""" + resp = Response(status=200) + resp.content_type = "text/event-stream" + resp.headers["Cache-Control"] = "no-cache" + resp.headers["X-Accel-Buffering"] = "no" + await resp.prepare(self.request) + + try: + async for chunk in ai_query_handler.generate_response_stream( + query_text, processed_results, schema_info, conversation_history + ): + sse_lines = "\n".join(f"data: {line}" for line in chunk.split("\n")) + await resp.write(f"{sse_lines}\n\n".encode("utf-8"), eof=False) + done = orjson.dumps( + { + "data": processed_results, + "conversation_id": conversation_id, + } + ).decode("utf-8") + await resp.write(f"event: done\ndata: {done}\n\n".encode("utf-8"), eof=False) + except Exception as e: + logger.error(f"Stream error: {e}", exc_info=True) + err = orjson.dumps({"error": "Stream error."}).decode("utf-8") + await resp.write(f"event: error\ndata: {err}\n\n".encode("utf-8"), eof=False) + await resp.write(b"", eof=True) + return resp + + def _prepare_conversation_history( + self, context: List[Dict], settings: dict + ) -> Optional[List[Dict]]: + """Prepare conversation history from context.""" + if not settings.get("enable_conversation", True): + return None + + if not context: + return None + + max_history = settings.get("max_conversation_history", 10) + return context[-max_history:] if len(context) > max_history else context diff --git a/guillotina/tests/test_ai_query.py b/guillotina/tests/test_ai_query.py new file mode 100644 index 000000000..abc2a0df1 --- /dev/null +++ b/guillotina/tests/test_ai_query.py @@ -0,0 +1,86 @@ +from guillotina.contrib.ai_query.result_processor import ResultProcessor +from guillotina.contrib.ai_query.schema_analyzer import SchemaAnalyzer +from guillotina.contrib.ai_query.handler import AIQueryHandler +from guillotina.tests import utils + +import json +import pytest + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) +async def test_schema_analyzer_discovers_content_types(container_requester): + async with container_requester as requester: + resp, status = await requester("GET", "/db/guillotina") + assert status == 200 + + container = await utils.get_container(requester) + analyzer = SchemaAnalyzer(container) + schema_info = await analyzer.get_schema_info() + + assert "content_types" in schema_info + assert "behaviors" in schema_info + assert "field_types" in schema_info + assert len(schema_info["content_types"]) > 0 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) +async def test_result_processor_sum_aggregation(): + items = [ + {"hours": 8.0, "developer": "Alice"}, + {"hours": 6.0, "developer": "Bob"}, + {"hours": 7.5, "developer": "Alice"}, + ] + + results = {"items": items, "items_total": 3} + aggregation = {"operation": "sum", "field": "hours"} + + processed = ResultProcessor.process_results(results, aggregation) + assert processed["total"] == 21.5 + assert processed["items_count"] == 3 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) +async def test_result_processor_count_aggregation(): + items = [ + {"type": "Document"}, + {"type": "Folder"}, + {"type": "Document"}, + ] + + results = {"items": items, "items_total": 3} + aggregation = {"operation": "count", "group_by": "type"} + + processed = ResultProcessor.process_results(results, aggregation) + assert "by_type" in processed + assert processed["by_type"]["Document"] == 2 + assert processed["by_type"]["Folder"] == 1 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) +async def test_llm_query_handler_available(container_requester): + async with container_requester as requester: + handler = AIQueryHandler() + assert handler is not None + assert hasattr(handler, "translate_query") + assert hasattr(handler, "generate_response") + assert hasattr(handler, "generate_response_stream") + assert hasattr(handler, "get_schema_info") + + +@pytest.mark.app_settings( + { + "applications": ["guillotina.contrib.ai_query"], + "ai_query": {"enabled": False}, + } +) +async def test_ai_query_disabled(container_requester): + async with container_requester as requester: + resp, status = await requester( + "POST", + "/db/guillotina/@ai-query", + data=json.dumps({"query": "test query"}), + ) + assert status == 503 From dade43315e1f5e9db1c0b075b36c8aa20fdb9c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Thu, 29 Jan 2026 09:13:33 +0100 Subject: [PATCH 02/36] feat: Enhance AI query handling with logging and multi-step support - Added LLMInteractionLogger for logging interactions with the LLM, configurable via app settings. - Introduced max_steps and logging options in app settings. - Updated AIQueryHandler to support multi-step query processing and logging of translation and response generation. - Enhanced prompts to include request context for better query resolution. - Improved error handling and logging throughout the AI query service. - Added request context retrieval in SchemaAnalyzer for better context awareness in queries. --- guillotina/contrib/ai_query/__init__.py | 3 + guillotina/contrib/ai_query/handler.py | 126 +++++++++-- guillotina/contrib/ai_query/llm_logger.py | 204 ++++++++++++++++++ guillotina/contrib/ai_query/prompts.py | 114 +++++++++- .../contrib/ai_query/result_processor.py | 10 +- .../contrib/ai_query/schema_analyzer.py | 22 ++ guillotina/contrib/ai_query/services.py | 159 ++++++++++++-- guillotina/tests/test_ai_query.py | 2 +- 8 files changed, 592 insertions(+), 48 deletions(-) create mode 100644 guillotina/contrib/ai_query/llm_logger.py diff --git a/guillotina/contrib/ai_query/__init__.py b/guillotina/contrib/ai_query/__init__.py index 151d06ec4..5bcb70135 100644 --- a/guillotina/contrib/ai_query/__init__.py +++ b/guillotina/contrib/ai_query/__init__.py @@ -19,6 +19,9 @@ "timeout": 30, "enable_conversation": True, "max_conversation_history": 10, + "max_steps": 5, + "log_llm_interactions": False, + "log_llm_dir": None, "litellm_settings": { "retry": {"attempts": 3}, }, diff --git a/guillotina/contrib/ai_query/handler.py b/guillotina/contrib/ai_query/handler.py index d0a4f1a9c..a1e00a868 100644 --- a/guillotina/contrib/ai_query/handler.py +++ b/guillotina/contrib/ai_query/handler.py @@ -1,4 +1,5 @@ from guillotina import app_settings +from guillotina.contrib.ai_query.llm_logger import LLMInteractionLogger from guillotina.contrib.ai_query.prompts import PromptBuilder from guillotina.contrib.ai_query.providers import LLMProvider from guillotina.contrib.ai_query.schema_analyzer import SchemaAnalyzer @@ -10,6 +11,7 @@ import json import logging +import time logger = logging.getLogger("guillotina") @@ -50,6 +52,7 @@ async def translate_query( context: IResource, schema_info: dict, conversation_history: Optional[List[Dict]] = None, + interaction_logger: Optional[LLMInteractionLogger] = None, ) -> dict: """ Translate natural language query to structured query format. @@ -68,32 +71,112 @@ async def translate_query( ] temperature = self.settings.get("temperature", 0.1) - max_tokens = self.settings.get( - "query_translation_max_tokens", self.settings.get("max_tokens", 1024) - ) + max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) timeout = self.settings.get("timeout", 30) try: + t0 = time.perf_counter() response_text = await self.provider.completion( messages=messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout, ) - query = self._parse_query_response(response_text) - self._validate_query(query, schema_info) + duration = time.perf_counter() - t0 + response = self._parse_query_response(response_text) + if interaction_logger: + interaction_logger.log_translate_query( + messages, response_text, response, duration_seconds=duration + ) + if self._is_step_response(response): + self._validate_query(response["query"], schema_info) + return response + self._validate_query(response, schema_info) + return response + except Exception as e: + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_llm_error( + "translate_query", e, messages=messages, duration_seconds=duration + ) + logger.error("Query translation failed: %s", e, exc_info=True) + raise + + async def translate_next_step( + self, + natural_language: str, + context: IResource, + schema_info: dict, + step_results: List[Dict], + conversation_history: Optional[List[Dict]] = None, + interaction_logger: Optional[LLMInteractionLogger] = None, + step_index: int = 1, + ) -> dict: + """ + Given previous step results, return either the next query to run + (with _next: true) or _action: answer. Used in multi-step agent loop. + """ + if not self.provider.is_enabled(): + raise ValueError("AI query is not enabled") + + system_prompt, user_prompt_template = PromptBuilder.build_next_step_prompt( + schema_info, step_results, conversation_history + ) + step_results_desc = PromptBuilder.format_step_results(step_results) + user_prompt = user_prompt_template.format(query=natural_language, step_results=step_results_desc) - return query + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + temperature = self.settings.get("temperature", 0.1) + max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) + timeout = self.settings.get("timeout", 30) + + try: + t0 = time.perf_counter() + response_text = await self.provider.completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + duration = time.perf_counter() - t0 + response = self._parse_query_response(response_text) + if interaction_logger: + interaction_logger.log_translate_next_step( + step_index, messages, response_text, response, duration_seconds=duration + ) + if response.get("_action") == "answer": + return response + if self._is_step_response(response): + self._validate_query(response["query"], schema_info) + return response + raise ValueError("Expected next-step query or _action answer from LLM") except Exception as e: - logger.error(f"Query translation failed: {e}", exc_info=True) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_llm_error( + f"translate_next_step (step {step_index})", + e, + messages=messages, + duration_seconds=duration, + ) + logger.error("Next step translation failed: %s", e, exc_info=True) raise + def _is_step_response(self, response: dict) -> bool: + """True if response is a step (has query and _next).""" + return isinstance(response.get("query"), dict) and response.get("_next") is True + async def generate_response( self, query: str, results: dict, schema_info: dict, conversation_history: Optional[List[Dict]] = None, + interaction_logger: Optional[LLMInteractionLogger] = None, ) -> str: """ Generate natural language response from query results. @@ -110,15 +193,24 @@ async def generate_response( timeout = self.settings.get("timeout", 30) try: + t0 = time.perf_counter() response = await self.provider.completion( messages=messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout, ) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_generate_response(messages, response, duration_seconds=duration) return response except Exception as e: - logger.error(f"Response generation failed: {e}", exc_info=True) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_llm_error( + "generate_response", e, messages=messages, duration_seconds=duration + ) + logger.error("Response generation failed: %s", e, exc_info=True) return f"Error generating response: {str(e)}" async def generate_response_stream( @@ -127,6 +219,7 @@ async def generate_response_stream( results: dict, schema_info: dict, conversation_history: Optional[List[Dict]] = None, + interaction_logger: Optional[LLMInteractionLogger] = None, ) -> AsyncGenerator[str, None]: """ Generate natural language response from query results; yields content chunks. @@ -144,15 +237,26 @@ async def generate_response_stream( timeout = self.settings.get("timeout", 30) try: + t0 = time.perf_counter() + chunks = [] async for chunk in self.provider.completion_stream( messages=messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout, ): + chunks.append(chunk) yield chunk + duration = time.perf_counter() - t0 + if interaction_logger and chunks: + interaction_logger.log_generate_response(messages, "".join(chunks), duration_seconds=duration) except Exception as e: - logger.error(f"Response stream failed: {e}", exc_info=True) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_llm_error( + "generate_response_stream", e, messages=messages, duration_seconds=duration + ) + logger.error("Response stream failed: %s", e, exc_info=True) yield f"Error generating response: {str(e)}" def _parse_query_response(self, response_text: str) -> dict: @@ -196,6 +300,4 @@ def _validate_query(self, query: dict, schema_info: dict): field_name = key.split("__")[0] if type_name and type_name in content_types: if field_name not in content_types[type_name]: - logger.warning( - f"Field {field_name} not found in {type_name}, but allowing query" - ) + logger.warning(f"Field {field_name} not found in {type_name}, but allowing query") diff --git a/guillotina/contrib/ai_query/llm_logger.py b/guillotina/contrib/ai_query/llm_logger.py new file mode 100644 index 000000000..967a3259c --- /dev/null +++ b/guillotina/contrib/ai_query/llm_logger.py @@ -0,0 +1,204 @@ +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +import json +import os + + +def _duration_line(seconds: Optional[float]) -> str: + if seconds is None: + return "" + return f" duration: {round(seconds * 1000)}ms\n" + + +def _summary(obj: Any, max_items: int = 10) -> str: + if isinstance(obj, dict): + if "items" in obj and isinstance(obj["items"], list): + total = obj.get("items_total", len(obj["items"])) + n = len(obj["items"]) + return f"items={n}, items_total={total}" + return json.dumps(obj, indent=2, default=str) + if isinstance(obj, list): + if len(obj) > max_items: + return ( + json.dumps(obj[:max_items], indent=2, default=str) + f"\n... and {len(obj) - max_items} more" + ) + return json.dumps(obj, indent=2, default=str) + return str(obj) + + +def format_result_summary(result: Dict) -> str: + """One-line summary of a search/aggregation result for step execution log.""" + if not result: + return "empty" + if "items" in result: + n = len(result.get("items", [])) + total = result.get("items_total", n) + return f"items={n}, items_total={total}" + if any(k in result for k in ("total", "count", "average", "overall_average")): + parts = [ + f"{k}={v}" for k, v in result.items() if k in ("total", "count", "average", "overall_average") + ] + return ", ".join(parts) + return _summary(result, max_items=3) + + +def _format_catalog_result(result: Dict, max_items: int = 50) -> str: + """Format catalog result for log: full if small, else summary + sample of items.""" + if not result: + return "empty" + if "items" in result and isinstance(result.get("items"), list): + items = result["items"] + total = result.get("items_total", len(items)) + if len(items) <= max_items: + return json.dumps(result, indent=2, default=str) + sample = {"items": items[:max_items], "items_total": total} + return json.dumps(sample, indent=2, default=str) + f"\n... and {len(items) - max_items} more items" + return json.dumps(result, indent=2, default=str) + + +class LLMInteractionLogger: + """ + Logs all LLM interactions for a single request to a file (one file per request). + Enable via ai_query.log_llm_interactions and set ai_query.log_llm_dir. + """ + + def __init__( + self, + request_id: str, + enabled: bool, + log_dir: Optional[str] = None, + ): + self.request_id = request_id + self.enabled = enabled and bool(log_dir) + self._path: Optional[str] = None + self._file = None + if self.enabled and log_dir: + os.makedirs(log_dir, exist_ok=True) + ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + self._path = os.path.join(log_dir, f"{ts}_{request_id[:8]}.log") + self._file = open(self._path, "w", encoding="utf-8") + self._write(f"=== LLM interaction log ===\nrequest_id: {request_id}\n") + + def _write(self, text: str) -> None: + if self._file: + self._file.write(text) + self._file.flush() + + def close(self) -> None: + if self._file: + try: + self._file.close() + except Exception: + pass + self._file = None + + def log_request_start(self, query_text: str) -> None: + if not self.enabled: + return + self._write(f"\n--- Request start ---\nquery: {query_text}\n") + + def log_translate_query( + self, + messages: List[Dict], + response_text: str, + parsed: Dict, + duration_seconds: Optional[float] = None, + ) -> None: + if not self.enabled: + return + self._write("\n--- translate_query (natural language -> structured query) ---\n") + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + self._write(f" [{role}]:\n{content}\n") + self._write(f" [LLM response]:\n{response_text}\n") + self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") + self._write(_duration_line(duration_seconds)) + + def log_translate_next_step( + self, + step_index: int, + messages: List[Dict], + response_text: str, + parsed: Dict, + duration_seconds: Optional[float] = None, + ) -> None: + if not self.enabled: + return + self._write(f"\n--- translate_next_step (step {step_index}) ---\n") + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + self._write(f" [{role}]:\n{content}\n") + self._write(f" [LLM response]:\n{response_text}\n") + self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") + self._write(_duration_line(duration_seconds)) + + def log_catalog_search( + self, + query: Dict, + result: Dict, + step_index: Optional[int] = None, + duration_seconds: Optional[float] = None, + ) -> None: + """Log a Guillotina catalog search: query sent and response received.""" + if not self.enabled: + return + if step_index is not None: + self._write(f"\n--- catalog search (step {step_index}) ---\n") + else: + self._write("\n--- catalog search ---\n") + self._write(f" query:\n{json.dumps(query, indent=2, default=str)}\n") + self._write(f" response:\n{_format_catalog_result(result)}\n") + self._write(_duration_line(duration_seconds)) + + def log_step_execution(self, step_index: int, query: Dict, result_summary: str) -> None: + if not self.enabled: + return + self._write(f"\n--- step {step_index} execution ---\n") + self._write(f" query: {json.dumps(query, indent=2, default=str)}\n") + self._write(f" result summary: {result_summary}\n") + + def log_generate_response( + self, + messages: List[Dict], + response_text: str, + duration_seconds: Optional[float] = None, + ) -> None: + if not self.enabled: + return + self._write("\n--- generate_response (results -> natural language) ---\n") + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + self._write(f" [{role}]:\n{content}\n") + self._write(f" [LLM response]:\n{response_text}\n") + self._write(_duration_line(duration_seconds)) + + def log_llm_error( + self, + step_name: str, + error: Exception, + messages: Optional[List[Dict]] = None, + duration_seconds: Optional[float] = None, + ) -> None: + """Log an LLM call failure (e.g. timeout, API error).""" + if not self.enabled: + return + self._write(f"\n--- {step_name} FAILED ---\n") + self._write(f" error: {type(error).__name__}: {error}\n") + self._write(_duration_line(duration_seconds)) + if messages: + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + self._write(f" [request {role}]:\n{content}\n") + + def log_request_end(self) -> None: + if not self.enabled: + return + self._write("\n--- Request end ---\n") diff --git a/guillotina/contrib/ai_query/prompts.py b/guillotina/contrib/ai_query/prompts.py index 9cc8d666d..24f65753b 100644 --- a/guillotina/contrib/ai_query/prompts.py +++ b/guillotina/contrib/ai_query/prompts.py @@ -15,10 +15,17 @@ def build_query_translation_prompt( """ content_types_desc = PromptBuilder._format_content_types(schema_info) field_types_desc = PromptBuilder._format_field_types(schema_info) + request_context_desc = PromptBuilder._format_request_context(schema_info) system_prompt = """You are a query translation assistant for Guillotina, a content management system. Your task is to translate natural language queries into structured JSON queries that match Guillotina's search syntax. +Current request context (the resource where the query is being run): +{request_context} + +Use this context when the user refers to "this" container, "here", or the current resource: set `path__starts` to request_context.path or `id__eq` to request_context.id so the search is scoped to the current context. +When the user refers to something by name or slug, resolve it via the schema: use the appropriate content type and filter by `title__in` or `title__eq` for names/titles, or `id__eq` for ids. + Available content types and their indexed fields: {content_types} @@ -34,16 +41,19 @@ def build_query_translation_prompt( - `__not`: does not contain - `__gt`, `__gte`, `__lt`, `__lte`: comparisons (for numeric/date fields) - `__wildcard`: wildcard pattern matching + - `path__starts`: filter by path prefix (use request_context.path for current container) - Use `_metadata` to specify which fields to return (comma-separated) - Use `_size` to limit results (max 50) - Use `_from` for pagination - Use `_sort_asc` or `_sort_des` for sorting +- For aggregations (sum, count, average) that must consider ALL matching items, set `_collect_all`: true so the system will paginate and aggregate over the full result set. Date handling: -- Relative dates: "aquesta setmana" = current week, "aquest mes" = current month -- Convert to ISO format: "YYYY-MM-DDTHH:MM:SSZ" +- Relative dates: convert to current week/month and then to ISO format "YYYY-MM-DDTHH:MM:SSZ" -Return ONLY valid JSON in this format: +Return ONLY valid JSON. Either a single query object, or a multi-step plan: + +Single query (use when one search is enough): {{ "type_name": "ContentTypeName", "field_name__operator": "value", @@ -51,12 +61,19 @@ def build_query_translation_prompt( "_size": 20 }} -If the query asks for aggregations (sum, count, average), include an "aggregation" field. -This is processed by the AI query layer in memory over search results (not by Guillotina's catalog): +Multi-step (use when you must first resolve a resource by name/title, then run another query using its path or id): +{{ + "query": {{ "type_name": "...", "title__in": "name to resolve", "_size": 1, "_metadata": "path,id,title" }}, + "_next": true +}} +The system will run the query, then ask you for the next step with the result. You will then return either the next {{ "query": {{ ... }}, "_next": true }} (use path or id from the first item of the previous result to set path__starts or a filter) or {{ "_action": "answer" }} when you have enough to answer. + +If the query asks for aggregations (sum, count, average), include an "aggregation" field and set "_collect_all": true so all matching items are fetched before aggregating: {{ "type_name": "ContentTypeName", "field_name__operator": "value", "_metadata": "field1,field2", + "_collect_all": true, "aggregation": {{ "operation": "sum|count|average", "field": "numeric_field_name", @@ -75,10 +92,7 @@ def build_query_translation_prompt( if conversation_history: history_text = "\n".join( - [ - f"{msg['role']}: {msg['content']}" - for msg in conversation_history[-5:] - ] + [f"{msg['role']}: {msg['content']}" for msg in conversation_history[-5:]] ) user_prompt_template = f"""Previous conversation: {history_text} @@ -86,6 +100,7 @@ def build_query_translation_prompt( {user_prompt_template}""" system_prompt_formatted = system_prompt.format( + request_context=request_context_desc, content_types=content_types_desc, field_types=field_types_desc, ) @@ -133,6 +148,66 @@ def build_response_generation_prompt( return messages + @staticmethod + def build_next_step_prompt( + schema_info: Dict, + step_results: List[Dict], + conversation_history: Optional[List[Dict]] = None, + ) -> tuple: + """ + Build prompt for the next step in a multi-step plan: given previous + step results, return either the next query or _action answer. + """ + content_types_desc = PromptBuilder._format_content_types(schema_info) + + system_prompt = """You are the next-step planner for a multi-step query in Guillotina. +The user asked a question that required running one or more searches. Previous step(s) have been run; below are their results. + +Available content types and indexed fields: +{content_types} + +Your response must be exactly one of: + +1) Next query to run (use path, id, or other fields from the previous step result to scope the search): +{{ "query": {{ "type_name": "...", "path__starts": "", ... }}, "_next": true }} + +2) Enough data to answer (no more queries): +{{ "_action": "answer" }} + +Use the first item from the previous step result (e.g. its "path" or "id") to set path__starts or filters for the next query. Return only valid JSON.""" + + user_prompt_template = """User question: {query} + +Previous step(s) results: +{step_results} + +Return either the next query JSON with "_next": true, or {{ "_action": "answer" }}. No other text.""" + + if conversation_history: + history_text = "\n".join(f"{m['role']}: {m['content']}" for m in conversation_history[-3:]) + user_prompt_template = f"Previous conversation:\n{history_text}\n\n{user_prompt_template}" + + system_formatted = system_prompt.format(content_types=content_types_desc) + return system_formatted, user_prompt_template + + @staticmethod + def format_step_results(step_results: List[Dict]) -> str: + """Format step results for the next-step prompt.""" + parts = [] + for i, step in enumerate(step_results, 1): + q = step.get("query", {}) + res = step.get("result", {}) + items = res.get("items", []) + total = res.get("items_total", len(items)) + parts.append(f"Step {i} query: {q}") + if total == 0: + parts.append(f"Step {i} result: no items") + elif total <= 3: + parts.append(f"Step {i} result ({total} items): {items}") + else: + parts.append(f"Step {i} result ({total} items, first 3): {items[:3]}") + return "\n".join(parts) + @staticmethod def _format_content_types(schema_info: Dict) -> str: """Format content types and fields for prompt.""" @@ -142,6 +217,19 @@ def _format_content_types(schema_info: Dict) -> str: lines.append(f"- {type_name}: {field_list}") return "\n".join(lines) if lines else "No content types found" + @staticmethod + def _format_request_context(schema_info: Dict) -> str: + """Format request context (path, id, type_name, title) for prompt.""" + ctx = schema_info.get("request_context", {}) + if not ctx: + return "Not available" + parts = [f"path: {ctx.get('path', '')}", f"id: {ctx.get('id', '')}"] + if ctx.get("type_name"): + parts.append(f"type_name: {ctx['type_name']}") + if ctx.get("title") is not None: + parts.append(f"title: {ctx['title']}") + return ", ".join(parts) + @staticmethod def _format_field_types(schema_info: Dict) -> str: """Format field type categories for prompt.""" @@ -155,6 +243,14 @@ def _format_field_types(schema_info: Dict) -> str: @staticmethod def _format_results(results: Dict) -> str: """Format query results for response generation.""" + if "items" not in results and any( + k in results for k in ("count", "total", "average", "overall_average") + ): + parts = [f"{k} = {v}" for k, v in results.items() if not k.startswith("by_")] + by_parts = [f"{k} = {v}" for k, v in results.items() if k.startswith("by_")] + if by_parts: + parts.extend(by_parts) + return "Aggregation result: " + ", ".join(parts) if "items" not in results: return "No results found" diff --git a/guillotina/contrib/ai_query/result_processor.py b/guillotina/contrib/ai_query/result_processor.py index 227f55867..11d5f578e 100644 --- a/guillotina/contrib/ai_query/result_processor.py +++ b/guillotina/contrib/ai_query/result_processor.py @@ -25,13 +25,17 @@ def process_results( return results items = results.get("items", []) - if not items: - return results - + items_total = results.get("items_total") operation = aggregation_config.get("operation") field = aggregation_config.get("field") group_by = aggregation_config.get("group_by") + if operation == "count" and not group_by: + total = items_total if items_total is not None else len(items) + return {"count": total} + if not items: + return results + if operation == "sum": return ResultProcessor._sum_aggregation(items, field, group_by) elif operation == "count": diff --git a/guillotina/contrib/ai_query/schema_analyzer.py b/guillotina/contrib/ai_query/schema_analyzer.py index 4ab1304f0..d39fd92e4 100644 --- a/guillotina/contrib/ai_query/schema_analyzer.py +++ b/guillotina/contrib/ai_query/schema_analyzer.py @@ -5,6 +5,7 @@ from guillotina.interfaces import IBehavior from guillotina.interfaces import IResource from guillotina.interfaces import IResourceFactory +from guillotina.utils import get_content_path import logging @@ -16,6 +17,26 @@ class SchemaAnalyzer: def __init__(self, context: IResource): self.context = context + def get_request_context(self) -> dict: + """ + Return current request context (path, id, type_name, title) so the LLM + can filter by "this container" or resolve references by id/title. + """ + path = get_content_path(self.context) + if path != "/" and not path.endswith("/"): + path = path + "/" + ctx = { + "path": path, + "id": getattr(self.context, "__name__", None) or getattr(self.context, "id", None), + } + type_name = getattr(self.context, "type_name", None) + if type_name: + ctx["type_name"] = type_name + title = getattr(self.context, "title", None) + if title is not None: + ctx["title"] = title + return ctx + async def get_schema_info(self) -> dict: """ Discover all content types, indexed fields, and field types dynamically. @@ -61,6 +82,7 @@ async def get_schema_info(self) -> dict: schema_info["behaviors"][behavior_name] = behavior_schema schema_info["field_types"] = self._categorize_field_types(schema_info) + schema_info["request_context"] = self.get_request_context() return schema_info diff --git a/guillotina/contrib/ai_query/services.py b/guillotina/contrib/ai_query/services.py index dcbcae187..07f703bec 100644 --- a/guillotina/contrib/ai_query/services.py +++ b/guillotina/contrib/ai_query/services.py @@ -2,8 +2,9 @@ from guillotina._settings import app_settings from guillotina.api.service import Service from guillotina.component import query_utility -from guillotina.contrib.ai_query.result_processor import ResultProcessor from guillotina.contrib.ai_query.handler import AIQueryHandler +from guillotina.contrib.ai_query.llm_logger import LLMInteractionLogger +from guillotina.contrib.ai_query.result_processor import ResultProcessor from guillotina.interfaces import ICatalogUtility from guillotina.interfaces import IResource from guillotina.response import HTTPPreconditionFailed @@ -14,8 +15,9 @@ from typing import Optional from uuid import uuid4 +import json import logging -import orjson +import time logger = logging.getLogger("guillotina") @@ -101,6 +103,16 @@ async def __call__(self): if not settings.get("enabled", True): raise HTTPServiceUnavailable(content={"reason": "AI query is not enabled"}) + request_id = str(uuid4()) + interaction_logger = None + if settings.get("log_llm_interactions") and settings.get("log_llm_dir"): + interaction_logger = LLMInteractionLogger( + request_id=request_id, + enabled=True, + log_dir=settings.get("log_llm_dir"), + ) + interaction_logger.log_request_start(query_text) + ai_query_handler = AIQueryHandler() try: @@ -109,19 +121,37 @@ async def __call__(self): conversation_history = self._prepare_conversation_history(context, settings) translated_query = await ai_query_handler.translate_query( - query_text, self.context, schema_info, conversation_history + query_text, + self.context, + schema_info, + conversation_history, + interaction_logger=interaction_logger, ) - import pdb; pdb.set_trace() - search_results = await self._execute_query(translated_query) - aggregation_config = translated_query.get("aggregation") - if aggregation_config: - processed_results = ResultProcessor.process_results( - search_results, aggregation_config + if ai_query_handler._is_step_response(translated_query): + processed_results, _ = await self._run_multi_step( + ai_query_handler, + query_text, + schema_info, + conversation_history, + translated_query, + settings, + interaction_logger=interaction_logger, ) else: - processed_results = search_results - import pdb; pdb.set_trace() + t0 = time.perf_counter() + search_results = await self._execute_query(translated_query) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_catalog_search( + translated_query, search_results, duration_seconds=duration + ) + aggregation_config = translated_query.get("aggregation") + if aggregation_config: + processed_results = ResultProcessor.process_results(search_results, aggregation_config) + else: + processed_results = search_results + if response_format == "natural": if stream: return await self._stream_response( @@ -131,9 +161,14 @@ async def __call__(self): schema_info, conversation_history, conversation_id, + interaction_logger=interaction_logger, ) answer = await ai_query_handler.generate_response( - query_text, processed_results, schema_info, conversation_history + query_text, + processed_results, + schema_info, + conversation_history, + interaction_logger=interaction_logger, ) return { "answer": answer, @@ -144,13 +179,15 @@ async def __call__(self): return processed_results except ValueError as e: - logger.error(f"Query validation error: {e}", exc_info=True) + logger.error("Query validation error: %s", e, exc_info=True) raise HTTPPreconditionFailed(content={"reason": str(e)}) except Exception as e: - logger.error(f"AI query error: {e}", exc_info=True) - raise HTTPServiceUnavailable( - content={"reason": f"Query processing failed: {str(e)}"} - ) + logger.error("AI query error: %s", e, exc_info=True) + raise HTTPServiceUnavailable(content={"reason": f"Query processing failed: {str(e)}"}) + finally: + if interaction_logger: + interaction_logger.log_request_end() + interaction_logger.close() async def _execute_query(self, query: dict) -> dict: """Execute the translated query using catalog utility.""" @@ -159,8 +196,28 @@ async def _execute_query(self, query: dict) -> dict: raise HTTPServiceUnavailable(content={"reason": "Catalog utility not available"}) aggregation = query.pop("aggregation", None) + collect_all = query.pop("_collect_all", False) try: + if collect_all and aggregation: + page_size = int(app_settings.get("catalog_max_results", 50)) + all_items: List[Dict] = [] + offset = 0 + total_known = None + while True: + q = {**query, "_from": offset, "_size": page_size} + batch = await catalog.search(self.context, q) + items = batch.get("items", []) + all_items.extend(items) + total_known = batch.get("items_total") + if total_known is not None and len(all_items) >= total_known: + break + if len(items) < page_size: + break + offset += len(items) + if aggregation: + query["aggregation"] = aggregation + return {"items": all_items, "items_total": len(all_items)} results = await catalog.search(self.context, query) if aggregation: query["aggregation"] = aggregation @@ -177,6 +234,7 @@ async def _stream_response( schema_info: dict, conversation_history: Optional[List[Dict]], conversation_id: str, + interaction_logger: Optional[LLMInteractionLogger] = None, ) -> Response: """Return SSE stream: event chunk with data, then event done with payload.""" resp = Response(status=200) @@ -187,11 +245,15 @@ async def _stream_response( try: async for chunk in ai_query_handler.generate_response_stream( - query_text, processed_results, schema_info, conversation_history + query_text, + processed_results, + schema_info, + conversation_history, + interaction_logger=interaction_logger, ): sse_lines = "\n".join(f"data: {line}" for line in chunk.split("\n")) await resp.write(f"{sse_lines}\n\n".encode("utf-8"), eof=False) - done = orjson.dumps( + done = json.dumps( { "data": processed_results, "conversation_id": conversation_id, @@ -200,14 +262,65 @@ async def _stream_response( await resp.write(f"event: done\ndata: {done}\n\n".encode("utf-8"), eof=False) except Exception as e: logger.error(f"Stream error: {e}", exc_info=True) - err = orjson.dumps({"error": "Stream error."}).decode("utf-8") + err = json.dumps({"error": "Stream error."}).decode("utf-8") await resp.write(f"event: error\ndata: {err}\n\n".encode("utf-8"), eof=False) await resp.write(b"", eof=True) return resp - def _prepare_conversation_history( - self, context: List[Dict], settings: dict - ) -> Optional[List[Dict]]: + async def _run_multi_step( + self, + ai_query_handler, + query_text: str, + schema_info: dict, + conversation_history: Optional[List[Dict]], + first_step_response: dict, + settings: dict, + interaction_logger: Optional[LLMInteractionLogger] = None, + ) -> tuple: + """ + Execute multi-step agent loop: run first query, then repeatedly + translate_next_step and execute until _action answer or max_steps. + Returns (processed_results_for_final_answer, step_results). + """ + max_steps = int(settings.get("max_steps", 5)) + step_results: List[Dict] = [] + current_query = dict(first_step_response["query"]) + last_processed: Optional[Dict] = None + + for step_index in range(1, max_steps + 1): + t0 = time.perf_counter() + search_results = await self._execute_query(current_query) + duration = time.perf_counter() - t0 + step_results.append({"query": dict(current_query), "result": search_results}) + if interaction_logger: + interaction_logger.log_catalog_search( + dict(current_query), + search_results, + step_index=step_index, + duration_seconds=duration, + ) + agg = current_query.get("aggregation") + last_processed = ResultProcessor.process_results(search_results, agg) if agg else search_results + + next_response = await ai_query_handler.translate_next_step( + query_text, + self.context, + schema_info, + step_results, + conversation_history, + interaction_logger=interaction_logger, + step_index=step_index, + ) + if next_response.get("_action") == "answer": + return (last_processed, step_results) + if ai_query_handler._is_step_response(next_response): + current_query = dict(next_response["query"]) + continue + break + + return (last_processed or {}, step_results) + + def _prepare_conversation_history(self, context: List[Dict], settings: dict) -> Optional[List[Dict]]: """Prepare conversation history from context.""" if not settings.get("enable_conversation", True): return None diff --git a/guillotina/tests/test_ai_query.py b/guillotina/tests/test_ai_query.py index abc2a0df1..02a622713 100644 --- a/guillotina/tests/test_ai_query.py +++ b/guillotina/tests/test_ai_query.py @@ -16,7 +16,7 @@ async def test_schema_analyzer_discovers_content_types(container_requester): resp, status = await requester("GET", "/db/guillotina") assert status == 200 - container = await utils.get_container(requester) + container = await utils.get_container(requester=requester) analyzer = SchemaAnalyzer(container) schema_info = await analyzer.get_schema_info() From 3f6dda71c8392dce77bd67c070cae11984e2c36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 11:25:31 +0100 Subject: [PATCH 03/36] feat: Implement retry mechanism for empty query results in AI query handling - Added `retry_on_empty` setting to app configuration to enable retrying queries that return no results. - Introduced `translate_retry_on_empty` method in `AIQueryHandler` to generate alternative queries or confirm no results. - Enhanced `LLMInteractionLogger` to log retry attempts and responses. - Updated `build_retry_on_empty_prompt` method in `PromptBuilder` to create prompts for suggesting alternative queries. - Modified `AIQueryService` to utilize the new retry mechanism when no results are found. --- guillotina/contrib/ai_query/__init__.py | 1 + guillotina/contrib/ai_query/handler.py | 62 +++++++++++++++++++++++ guillotina/contrib/ai_query/llm_logger.py | 19 +++++++ guillotina/contrib/ai_query/prompts.py | 50 ++++++++++++++++++ guillotina/contrib/ai_query/services.py | 25 +++++++++ 5 files changed, 157 insertions(+) diff --git a/guillotina/contrib/ai_query/__init__.py b/guillotina/contrib/ai_query/__init__.py index 5bcb70135..6ad854a04 100644 --- a/guillotina/contrib/ai_query/__init__.py +++ b/guillotina/contrib/ai_query/__init__.py @@ -20,6 +20,7 @@ "enable_conversation": True, "max_conversation_history": 10, "max_steps": 5, + "retry_on_empty": True, "log_llm_interactions": False, "log_llm_dir": None, "litellm_settings": { diff --git a/guillotina/contrib/ai_query/handler.py b/guillotina/contrib/ai_query/handler.py index a1e00a868..34ac9f558 100644 --- a/guillotina/contrib/ai_query/handler.py +++ b/guillotina/contrib/ai_query/handler.py @@ -170,6 +170,68 @@ def _is_step_response(self, response: dict) -> bool: """True if response is a step (has query and _next).""" return isinstance(response.get("query"), dict) and response.get("_next") is True + async def translate_retry_on_empty( + self, + natural_language: str, + context: IResource, + schema_info: dict, + last_query: dict, + conversation_history: Optional[List[Dict]] = None, + interaction_logger: Optional[LLMInteractionLogger] = None, + ) -> dict: + """ + When the last query returned 0 results, ask the LLM for one alternative + query or to confirm no results. Returns {"query": {...}, "_next": true} + or {"_action": "answer"}. + """ + if not self.provider.is_enabled(): + raise ValueError("AI query is not enabled") + + t0 = time.perf_counter() + system_prompt, user_prompt_template = PromptBuilder.build_retry_on_empty_prompt( + schema_info, natural_language, last_query, conversation_history + ) + user_prompt = user_prompt_template.format( + user_query=natural_language, + last_query=json.dumps(last_query, indent=2, default=str), + ) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + temperature = self.settings.get("temperature", 0.1) + max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) + timeout = self.settings.get("timeout", 30) + + try: + response_text = await self.provider.completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + duration = time.perf_counter() - t0 + response = self._parse_query_response(response_text) + if interaction_logger: + interaction_logger.log_retry_on_empty( + messages, response_text, response, duration_seconds=duration + ) + if response.get("_action") == "answer": + return response + if self._is_step_response(response): + self._validate_query(response["query"], schema_info) + return response + raise ValueError("Expected alternative query or _action answer from LLM") + except Exception as e: + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_llm_error( + "retry_on_empty", e, messages=messages, duration_seconds=duration + ) + logger.error("Retry on empty failed: %s", e, exc_info=True) + raise + async def generate_response( self, query: str, diff --git a/guillotina/contrib/ai_query/llm_logger.py b/guillotina/contrib/ai_query/llm_logger.py index 967a3259c..cf5553b4c 100644 --- a/guillotina/contrib/ai_query/llm_logger.py +++ b/guillotina/contrib/ai_query/llm_logger.py @@ -179,6 +179,25 @@ def log_generate_response( self._write(f" [LLM response]:\n{response_text}\n") self._write(_duration_line(duration_seconds)) + def log_retry_on_empty( + self, + messages: List[Dict], + response_text: str, + parsed: Dict, + duration_seconds: Optional[float] = None, + ) -> None: + """Log the retry-on-empty LLM call (alternative query or _action answer).""" + if not self.enabled: + return + self._write("\n--- retry_on_empty (0 results, suggest alternative or confirm) ---\n") + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + self._write(f" [{role}]:\n{content}\n") + self._write(f" [LLM response]:\n{response_text}\n") + self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") + self._write(_duration_line(duration_seconds)) + def log_llm_error( self, step_name: str, diff --git a/guillotina/contrib/ai_query/prompts.py b/guillotina/contrib/ai_query/prompts.py index 24f65753b..23ea62952 100644 --- a/guillotina/contrib/ai_query/prompts.py +++ b/guillotina/contrib/ai_query/prompts.py @@ -190,6 +190,56 @@ def build_next_step_prompt( system_formatted = system_prompt.format(content_types=content_types_desc) return system_formatted, user_prompt_template + @staticmethod + def build_retry_on_empty_prompt( + schema_info: Dict, + user_query: str, + last_query: Dict, + conversation_history: Optional[List[Dict]] = None, + ) -> Tuple[str, str]: + """ + Build prompt for retry when the last query returned 0 results: suggest + one alternative query or confirm no results. + """ + content_types_desc = PromptBuilder._format_content_types(schema_info) + request_context_desc = PromptBuilder._format_request_context(schema_info) + + system_prompt = """You are a query assistant for Guillotina. The last catalog query returned zero results. +Either the data does not exist, or the query was too strict (wrong type, field, or operator). + +Current request context: +{request_context} + +Available content types and indexed fields: +{content_types} + +You must return exactly one of: + +1) One alternative query to try (e.g. different type_name, use title__wildcard instead of title__in, or id__eq instead of title, or looser path): +{{ "query": {{ "type_name": "...", ... }}, "_next": true }} + +2) Confirm there are no results (do not retry): +{{ "_action": "answer" }} + +Return only valid JSON. No other text.""" + + user_prompt_template = """User question: {user_query} + +Last query (returned 0 results): +{last_query} + +Return either one alternative query with "_next": true, or {{ "_action": "answer" }}.""" + + if conversation_history: + history_text = "\n".join(f"{m['role']}: {m['content']}" for m in conversation_history[-3:]) + user_prompt_template = f"Previous conversation:\n{history_text}\n\n{user_prompt_template}" + + system_formatted = system_prompt.format( + request_context=request_context_desc, + content_types=content_types_desc, + ) + return system_formatted, user_prompt_template + @staticmethod def format_step_results(step_results: List[Dict]) -> str: """Format step results for the next-step prompt.""" diff --git a/guillotina/contrib/ai_query/services.py b/guillotina/contrib/ai_query/services.py index 07f703bec..fd291d3e6 100644 --- a/guillotina/contrib/ai_query/services.py +++ b/guillotina/contrib/ai_query/services.py @@ -146,6 +146,31 @@ async def __call__(self): interaction_logger.log_catalog_search( translated_query, search_results, duration_seconds=duration ) + if ( + search_results.get("items_total", 0) == 0 + and settings.get("retry_on_empty", True) + and not translated_query.get("aggregation") + ): + retry_response = await ai_query_handler.translate_retry_on_empty( + query_text, + self.context, + schema_info, + translated_query, + conversation_history, + interaction_logger=interaction_logger, + ) + if ai_query_handler._is_step_response(retry_response): + alt_query = dict(retry_response["query"]) + t0 = time.perf_counter() + search_results = await self._execute_query(alt_query) + duration = time.perf_counter() - t0 + if interaction_logger: + interaction_logger.log_catalog_search( + alt_query, + search_results, + duration_seconds=duration, + ) + translated_query = alt_query aggregation_config = translated_query.get("aggregation") if aggregation_config: processed_results = ResultProcessor.process_results(search_results, aggregation_config) From a30945b83ac322112e9c04fb6bfb63dce070fd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 11:26:05 +0100 Subject: [PATCH 04/36] feat: Introduce MCP integration --- guillotina/contrib/mcp/__init__.py | 24 +++ guillotina/contrib/mcp/backend.py | 249 ++++++++++++++++++++++++++ guillotina/contrib/mcp/command.py | 56 ++++++ guillotina/contrib/mcp/interfaces.py | 5 + guillotina/contrib/mcp/permissions.py | 5 + guillotina/contrib/mcp/server.py | 37 ++++ guillotina/contrib/mcp/services.py | 66 +++++++ guillotina/contrib/mcp/tools.py | 89 +++++++++ guillotina/tests/test_mcp.py | 23 +++ 9 files changed, 554 insertions(+) create mode 100644 guillotina/contrib/mcp/__init__.py create mode 100644 guillotina/contrib/mcp/backend.py create mode 100644 guillotina/contrib/mcp/command.py create mode 100644 guillotina/contrib/mcp/interfaces.py create mode 100644 guillotina/contrib/mcp/permissions.py create mode 100644 guillotina/contrib/mcp/server.py create mode 100644 guillotina/contrib/mcp/services.py create mode 100644 guillotina/contrib/mcp/tools.py create mode 100644 guillotina/tests/test_mcp.py diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py new file mode 100644 index 000000000..cc849dd1e --- /dev/null +++ b/guillotina/contrib/mcp/__init__.py @@ -0,0 +1,24 @@ +from guillotina import configure + + +app_settings = { + "mcp": { + "enabled": True, + "base_url": None, + "auth": { + "type": "basic", + "username": "root", + "password": None, + }, + "description_extras": {}, + "extra_tools_module": None, + }, + "commands": { + "mcp-server": "guillotina.contrib.mcp.command.MCPServerCommand", + }, +} + + +def includeme(root, settings): + configure.scan("guillotina.contrib.mcp.permissions") + configure.scan("guillotina.contrib.mcp.services") diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py new file mode 100644 index 000000000..273b7af42 --- /dev/null +++ b/guillotina/contrib/mcp/backend.py @@ -0,0 +1,249 @@ +from guillotina.component import get_multi_adapter +from guillotina.component import query_utility +from guillotina.interfaces import ICatalogUtility +from guillotina.interfaces import IResource +from guillotina.interfaces import IResourceSerializeToJson +from guillotina.utils import get_object_by_uid +from guillotina.utils import navigate_to +from zope.interface import Interface + +import typing + + +class IMCPBackend(Interface): + async def search(context: IResource, query: dict) -> dict: + pass + + async def count(context: IResource, query: dict) -> int: + pass + + async def get_content(context: IResource, path: typing.Optional[str], uid: typing.Optional[str]) -> dict: + pass + + async def list_children( + context: IResource, + path: str, + _from: int = 0, + _size: int = 20, + ) -> dict: + pass + + +from contextvars import ContextVar + +_mcp_context_var: ContextVar[typing.Optional[IResource]] = ContextVar("mcp_context", default=None) + + +def get_mcp_context(): + return _mcp_context_var.get() + + +def set_mcp_context(context: IResource): + _mcp_context_var.set(context) + + +def clear_mcp_context(): + try: + _mcp_context_var.set(None) + except LookupError: + pass + + +class InProcessBackend: + def _get_base_context(self) -> IResource: + ctx = get_mcp_context() + if ctx is None: + raise RuntimeError("MCP context not set (not in @mcp request?)") + return ctx + + async def search(self, context: IResource, query: dict) -> dict: + base = context if context is not None else self._get_base_context() + search = query_utility(ICatalogUtility) + if search is None: + return {"items": [], "items_total": 0} + return await search.search(base, query) + + async def count(self, context: IResource, query: dict) -> int: + base = context if context is not None else self._get_base_context() + search = query_utility(ICatalogUtility) + if search is None: + return 0 + return await search.count(base, query) + + async def get_content( + self, + context: IResource, + path: typing.Optional[str], + uid: typing.Optional[str], + ) -> dict: + from guillotina import task_vars + + base = context if context is not None else self._get_base_context() + request = task_vars.request.get() + if uid: + try: + ob = await get_object_by_uid(uid) + except KeyError: + return {} + if not self._in_container_tree(ob, base): + return {} + elif path is not None: + rel_path = path.strip("/") or "" + try: + ob = await navigate_to(base, "/" + rel_path) if rel_path else base + except KeyError: + return {} + else: + return {} + serializer = get_multi_adapter((ob, request), IResourceSerializeToJson) + return await serializer() + + def _in_container_tree(self, ob: IResource, container: IResource) -> bool: + from guillotina.utils import get_content_path + + ob_path = get_content_path(ob) + cont_path = get_content_path(container) + return ob_path == cont_path or ob_path.startswith(cont_path.rstrip("/") + "/") + + async def list_children( + self, + context: IResource, + path: str, + _from: int = 0, + _size: int = 20, + ) -> dict: + base = context if context is not None else self._get_base_context() + path = path.strip("/") or "" + try: + container = await navigate_to(base, "/" + path) if path else base + except KeyError: + return {"items": [], "items_total": 0} + from guillotina import task_vars + from guillotina.interfaces import IFolder + from guillotina.interfaces import IResourceSerializeToJsonSummary + + if not IFolder.providedBy(container): + return {"items": [], "items_total": 0} + request = task_vars.request.get() + items = [] + total = 0 + async for name, child in container.async_items(): + if total >= _from + _size: + total += 1 + continue + if total >= _from: + summary_serializer = get_multi_adapter((child, request), IResourceSerializeToJsonSummary) + items.append(await summary_serializer()) + total += 1 + return {"items": items, "items_total": total} + + +def _encode_basic_auth(username: str, password: str) -> str: + import base64 + + credentials = f"{username}:{password or ''}" + return base64.b64encode(credentials.encode()).decode() + + +class HttpBackend: + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password or "" + self._auth_header = f"Basic {_encode_basic_auth(username, password)}" + + def _url(self, path: str, query: typing.Optional[dict] = None) -> str: + path = path.strip("/") + url = f"{self.base_url}/{path}" + if query: + from urllib.parse import urlencode + + url += "?" + urlencode(query) + return url + + async def search(self, context: IResource, query: dict) -> dict: + import httpx + + path = context if isinstance(context, str) else None + if not path: + return {"items": [], "items_total": 0} + url = self._url(f"{path}/@search", query) + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) + resp.raise_for_status() + return resp.json() + + async def count(self, context: IResource, query: dict) -> int: + import httpx + + path = context if isinstance(context, str) else None + if not path: + return 0 + url = self._url(f"{path}/@count", query) + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) + resp.raise_for_status() + return resp.json() + + async def get_content( + self, + context: IResource, + path: typing.Optional[str], + uid: typing.Optional[str], + ) -> dict: + import httpx + + base_path = context if isinstance(context, str) else None + if not base_path: + return {} + if uid: + url = self._url(f"{base_path}/@resolveuid/{uid}") + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) + if resp.status_code == 404: + return {} + resp.raise_for_status() + target = resp.headers.get("location") or "" + if not target.startswith("http"): + return {} + resp2 = await client.get(target, headers={"Authorization": self._auth_header}, timeout=30.0) + resp2.raise_for_status() + return resp2.json() + elif path is not None: + rel = path.strip("/") + url_path = f"{base_path}/{rel}" if rel else base_path + url = self._url(url_path) + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) + if resp.status_code == 404: + return {} + resp.raise_for_status() + return resp.json() + return {} + + async def list_children( + self, + context: IResource, + path: str, + _from: int = 0, + _size: int = 20, + ) -> dict: + import httpx + + base_path = context if isinstance(context, str) else None + if not base_path: + return {"items": [], "items_total": 0} + rel = path.strip("/") if path else "" + url_path = f"{base_path}/{rel}" if rel else base_path + page = (_from // _size) + 1 if _size else 1 + query = {"page": str(page), "page_size": str(_size or 20)} + url = self._url(f"{url_path}/@items", query) + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) + if resp.status_code == 404: + return {"items": [], "items_total": 0} + resp.raise_for_status() + data = resp.json() + items = data.get("items", []) if isinstance(data, dict) else [] + total = data.get("total", len(items)) if isinstance(data, dict) else len(items) + return {"items": items, "items_total": total} diff --git a/guillotina/contrib/mcp/command.py b/guillotina/contrib/mcp/command.py new file mode 100644 index 000000000..464430c0a --- /dev/null +++ b/guillotina/contrib/mcp/command.py @@ -0,0 +1,56 @@ +from guillotina.commands import Command +from guillotina.contrib.mcp.backend import HttpBackend +from guillotina.contrib.mcp.server import get_mcp_server + +import argparse +import asyncio +import logging +import os + + +logger = logging.getLogger("guillotina") + + +class MCPServerCommand(Command): + description = "Run MCP server (out-of-process) that connects to Guillotina via REST." + + def get_parser(self): + parser = super(MCPServerCommand, self).get_parser() + parser.add_argument("--base-url", help="Guillotina base URL (e.g. http://localhost:8080)") + parser.add_argument("--username", help="Basic auth username") + parser.add_argument("--password", help="Basic auth password") + parser.add_argument("--host", default="0.0.0.0", help="MCP server host") + parser.add_argument("--port", type=int, default=8000, help="MCP server port") + return parser + + async def run(self, arguments, settings, app): + mcp_settings = settings.get("mcp", {}) + auth = mcp_settings.get("auth", {}) + base_url = ( + getattr(arguments, "base_url", None) + or mcp_settings.get("base_url") + or os.environ.get("MCP_GUILLOTINA_BASE_URL") + ) + username = ( + getattr(arguments, "username", None) + or auth.get("username") + or os.environ.get("MCP_GUILLOTINA_USERNAME", "root") + ) + password = ( + getattr(arguments, "password", None) + or auth.get("password") + or os.environ.get("MCP_GUILLOTINA_PASSWORD", "") + ) + if not base_url: + logger.error("base_url is required (config mcp.base_url, --base-url, or MCP_GUILLOTINA_BASE_URL)") + return + backend = HttpBackend(base_url=base_url, username=username, password=password) + server = get_mcp_server(backend) + host = getattr(arguments, "host", "0.0.0.0") + port = getattr(arguments, "port", 8000) + + def run_server(): + server.run(transport="streamable-http", host=host, port=port) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_server) diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py new file mode 100644 index 000000000..f7d85d6c6 --- /dev/null +++ b/guillotina/contrib/mcp/interfaces.py @@ -0,0 +1,5 @@ +from zope.interface import Interface + + +class IMCPDescriptionExtras(Interface): + """Callable utility returning a dict mapping tool name to extra description text (appended to the base tool description for LLM context). Tool names: search, count, get_content, list_children.""" diff --git a/guillotina/contrib/mcp/permissions.py b/guillotina/contrib/mcp/permissions.py new file mode 100644 index 000000000..e2b727440 --- /dev/null +++ b/guillotina/contrib/mcp/permissions.py @@ -0,0 +1,5 @@ +from guillotina import configure + + +configure.permission("guillotina.mcp.Use", "Use MCP tools to query content") +configure.grant(permission="guillotina.mcp.Use", role="guillotina.Authenticated") diff --git a/guillotina/contrib/mcp/server.py b/guillotina/contrib/mcp/server.py new file mode 100644 index 000000000..72ed0a469 --- /dev/null +++ b/guillotina/contrib/mcp/server.py @@ -0,0 +1,37 @@ +from guillotina._settings import app_settings +from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.contrib.mcp.tools import register_tools + +_mcp_server_instance = None + + +def get_mcp_server(backend=None): + from mcp.server.fastmcp import FastMCP + + if backend is None: + backend = InProcessBackend() + mcp = FastMCP( + "Guillotina MCP", + json_response=True, + stateless_http=True, + ) + if hasattr(mcp, "settings") and hasattr(mcp.settings, "streamable_http_path"): + mcp.settings.streamable_http_path = "/" + register_tools(mcp, backend) + extra_module = app_settings.get("mcp", {}).get("extra_tools_module") + if extra_module: + mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) + getattr(mod, "register_extra_tools")(mcp, backend) + return mcp + + +def get_mcp_server_instance(): + return _mcp_server_instance + + +def get_mcp_asgi_app(backend=None): + global _mcp_server_instance + server = get_mcp_server(backend) + app = server.streamable_http_app() + _mcp_server_instance = server + return app diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py new file mode 100644 index 000000000..ef0172855 --- /dev/null +++ b/guillotina/contrib/mcp/services.py @@ -0,0 +1,66 @@ +from guillotina import configure +from guillotina._settings import app_settings +from guillotina.contrib.mcp.backend import clear_mcp_context +from guillotina.contrib.mcp.backend import set_mcp_context +from guillotina.contrib.mcp.server import get_mcp_asgi_app +from guillotina.contrib.mcp.server import get_mcp_server_instance +from guillotina.interfaces import IResource +from guillotina.response import Response + +import anyio +import copy +import logging + + +logger = logging.getLogger("guillotina") + +_mcp_asgi_app = None + + +def _get_mcp_app(): + global _mcp_asgi_app + if _mcp_asgi_app is None: + _mcp_asgi_app = get_mcp_asgi_app() + return _mcp_asgi_app + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.mcp.Use", + name="@mcp", + summary="MCP protocol endpoint (POST)", +) +@configure.service( + context=IResource, + method="GET", + permission="guillotina.mcp.Use", + name="@mcp", + summary="MCP protocol endpoint (GET)", +) +async def mcp_service(context, request): + if not app_settings.get("mcp", {}).get("enabled", True): + from guillotina.response import HTTPNotFound + + raise HTTPNotFound(content={"reason": "MCP is disabled"}) + set_mcp_context(context) + try: + scope = copy.copy(request.scope) + scope["path"] = "/" + scope["raw_path"] = b"/" + app = _get_mcp_app() + server = get_mcp_server_instance() + session_manager = server.session_manager + original_task_group = session_manager._task_group + async with anyio.create_task_group() as tg: + session_manager._task_group = tg + try: + await app(scope, request.receive, request.send) + finally: + session_manager._task_group = original_task_group + finally: + clear_mcp_context() + resp = Response() + resp._prepared = True + resp._eof_sent = True + return resp diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py new file mode 100644 index 000000000..9da5b7582 --- /dev/null +++ b/guillotina/contrib/mcp/tools.py @@ -0,0 +1,89 @@ +from guillotina._settings import app_settings +from guillotina.contrib.mcp.backend import get_mcp_context +from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras + +import typing + +TOOL_DESCRIPTIONS = { + "search": "Search the catalog. container_path is optional (default: current context). query keys follow Guillotina @search: type_name, term, _size, _from, _sort_asc (field name for ascending), _sort_des (field name for descending), _metadata, _metadata_not; field filters: field__eq, field__not, field__gt, field__gte, field__lt, field__lte, field__in. E.g. creators__in to filter by creator (value: user id or list).", + "count": "Count catalog results. container_path is optional. query uses same keys as search: type_name, term, field__eq, field__gt, etc. (no _size/_from/_sort_asc/_sort_des).", + "get_content": "Get a resource by path (relative to container) or by UID. container_path is optional for in-process.", + "list_children": "List direct children of a container. path: relative path to container. from_index: offset (maps to _from). page_size: page size (maps to _size). container_path is optional for in-process.", +} + + +def _get_description_extras(): + from guillotina.component import query_utility + + extras = dict(app_settings.get("mcp", {}).get("description_extras") or {}) + util = query_utility(IMCPDescriptionExtras) + if util is not None: + for k, v in (util() or {}).items(): + extras[k] = (extras.get(k) or "") + (" " + v if v else "") + return extras + + +def register_tools(mcp_server, backend): + def _context_for_path(container_path: typing.Optional[str]): + ctx = get_mcp_context() + if ctx is not None and container_path: + from guillotina.utils import navigate_to + + try: + return navigate_to(ctx, "/" + container_path.strip("/")) + except KeyError: + return None + if ctx is not None: + return ctx + return container_path + + extras = _get_description_extras() + + @mcp_server.tool(description=(TOOL_DESCRIPTIONS["search"] + " " + (extras.get("search") or "")).strip()) + async def search( + container_path: typing.Optional[str] = None, + query: typing.Optional[typing.Dict[str, str]] = None, + ) -> dict: + context = _context_for_path(container_path) + if context is None and container_path is None: + return {"items": [], "items_total": 0} + q = query or {} + return await backend.search(context, q) + + @mcp_server.tool(description=(TOOL_DESCRIPTIONS["count"] + " " + (extras.get("count") or "")).strip()) + async def count( + container_path: typing.Optional[str] = None, + query: typing.Optional[typing.Dict[str, str]] = None, + ): + context = _context_for_path(container_path) + if context is None and container_path is None: + return 0 + q = query or {} + return await backend.count(context, q) + + @mcp_server.tool( + description=(TOOL_DESCRIPTIONS["get_content"] + " " + (extras.get("get_content") or "")).strip() + ) + async def get_content( + path: typing.Optional[str] = None, + uid: typing.Optional[str] = None, + container_path: typing.Optional[str] = None, + ) -> dict: + context = _context_for_path(container_path) + if context is None and container_path is None: + return {} + return await backend.get_content(context, path, uid) + + @mcp_server.tool( + description=(TOOL_DESCRIPTIONS["list_children"] + " " + (extras.get("list_children") or "")).strip() + ) + async def list_children( + path: str = "", + from_index: int = 0, + page_size: int = 20, + container_path: typing.Optional[str] = None, + ) -> dict: + context = _context_for_path(container_path) + if context is None and container_path is None: + return {"items": [], "items_total": 0} + return await backend.list_children(context, path or "", from_index, page_size) diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py new file mode 100644 index 000000000..6f6945b6e --- /dev/null +++ b/guillotina/tests/test_mcp.py @@ -0,0 +1,23 @@ +import pytest + +from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.contrib.mcp.backend import clear_mcp_context + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_mcp_service_registered(container_requester): + pytest.importorskip("mcp") + async with container_requester as requester: + resp, status = await requester("GET", "/db/guillotina/@mcp") + assert status in (200, 401, 404) + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_inprocess_backend_search_requires_context(container_requester): + backend = InProcessBackend() + clear_mcp_context() + with pytest.raises(RuntimeError, match="MCP context not set"): + await backend.search(None, {}) From 3052880e4363a181d838a032dcd9271ac1ab4104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 13:46:16 +0100 Subject: [PATCH 05/36] feat: Add token issuance for MCP with configurable duration --- guillotina/contrib/mcp/__init__.py | 2 + guillotina/contrib/mcp/permissions.py | 2 + guillotina/contrib/mcp/services.py | 65 +++++++++++++++++++++++++++ guillotina/tests/test_mcp.py | 25 +++++++++++ 4 files changed, 94 insertions(+) diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index cc849dd1e..fd6111822 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -12,6 +12,8 @@ }, "description_extras": {}, "extra_tools_module": None, + "token_max_duration_days": 90, + "token_allowed_durations": None, }, "commands": { "mcp-server": "guillotina.contrib.mcp.command.MCPServerCommand", diff --git a/guillotina/contrib/mcp/permissions.py b/guillotina/contrib/mcp/permissions.py index e2b727440..2a67e9dea 100644 --- a/guillotina/contrib/mcp/permissions.py +++ b/guillotina/contrib/mcp/permissions.py @@ -3,3 +3,5 @@ configure.permission("guillotina.mcp.Use", "Use MCP tools to query content") configure.grant(permission="guillotina.mcp.Use", role="guillotina.Authenticated") +configure.permission("guillotina.mcp.IssueToken", "Issue a long-lived MCP token") +configure.grant(permission="guillotina.mcp.IssueToken", role="guillotina.Authenticated") diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index ef0172855..921c1876b 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -1,11 +1,17 @@ from guillotina import configure from guillotina._settings import app_settings +from guillotina.api.service import Service +from guillotina.auth import authenticate_user +from guillotina.auth.users import AnonymousUser from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import set_mcp_context from guillotina.contrib.mcp.server import get_mcp_asgi_app from guillotina.contrib.mcp.server import get_mcp_server_instance from guillotina.interfaces import IResource +from guillotina.response import HTTPPreconditionFailed +from guillotina.response import HTTPUnauthorized from guillotina.response import Response +from guillotina.utils import get_authenticated_user import anyio import copy @@ -64,3 +70,62 @@ async def mcp_service(context, request): resp._prepared = True resp._eof_sent = True return resp + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.mcp.IssueToken", + name="@mcp-token", + summary="Issue a long-lived JWT for MCP client configuration", +) +class MCPToken(Service): + __body_required__ = False + + async def __call__(self): + if not app_settings.get("mcp", {}).get("enabled", True): + raise HTTPPreconditionFailed(content={"reason": "MCP is disabled"}) + user = get_authenticated_user() + if user is None or isinstance(user, AnonymousUser): + raise HTTPUnauthorized(content={"reason": "Authentication required"}) + try: + body = await self.request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + duration_days = body.get("duration_days", 30) + try: + duration_days = int(duration_days) + except (TypeError, ValueError): + raise HTTPPreconditionFailed( + content={"reason": "duration_days must be an integer", "value": duration_days} + ) + mcp_settings = app_settings.get("mcp", {}) + max_days = mcp_settings.get("token_max_duration_days", 90) + allowed = mcp_settings.get("token_allowed_durations") + if allowed is not None: + if duration_days not in allowed: + raise HTTPPreconditionFailed( + content={ + "reason": "duration_days must be one of", + "allowed": allowed, + "value": duration_days, + } + ) + else: + if duration_days < 1 or duration_days > max_days: + raise HTTPPreconditionFailed( + content={ + "reason": "duration_days must be between 1 and token_max_duration_days", + "token_max_duration_days": max_days, + "value": duration_days, + } + ) + timeout = duration_days * 24 * 3600 + jwt_token, data = authenticate_user(user.id, data={"purpose": "mcp"}, timeout=timeout) + return { + "token": jwt_token, + "exp": data["exp"], + "duration_days": duration_days, + } diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 6f6945b6e..9fb534556 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -1,3 +1,4 @@ +import json import pytest from guillotina.contrib.mcp.backend import InProcessBackend @@ -21,3 +22,27 @@ async def test_inprocess_backend_search_requires_context(container_requester): clear_mcp_context() with pytest.raises(RuntimeError, match="MCP context not set"): await backend.search(None, {}) + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_mcp_token_requires_auth(container_requester): + async with container_requester as requester: + _, status = await requester("POST", "/db/guillotina/@mcp-token", authenticated=False) + assert status == 401 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_mcp_token_returns_long_lived_token(container_requester): + async with container_requester as requester: + resp, status = await requester("POST", "/db/guillotina/@mcp-token") + assert status == 200 + assert "token" in resp + assert "exp" in resp + assert resp.get("duration_days") == 30 + resp_custom, status_custom = await requester( + "POST", + "/db/guillotina/@mcp-token", + data=json.dumps({"duration_days": 60}), + ) + assert status_custom == 200 + assert resp_custom.get("duration_days") == 60 From 0524444fed66d602aeb744146a52b2f658bc0ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 14:09:45 +0100 Subject: [PATCH 06/36] feat: Remove AI query module and related components --- guillotina/contrib/ai_query/README.md | 176 --------- guillotina/contrib/ai_query/__init__.py | 34 -- guillotina/contrib/ai_query/handler.py | 365 ------------------ guillotina/contrib/ai_query/llm_logger.py | 223 ----------- guillotina/contrib/ai_query/prompts.py | 318 --------------- guillotina/contrib/ai_query/providers.py | 152 -------- .../contrib/ai_query/result_processor.py | 112 ------ .../contrib/ai_query/schema_analyzer.py | 117 ------ guillotina/contrib/ai_query/services.py | 357 ----------------- guillotina/contrib/mcp/backend.py | 3 +- guillotina/contrib/mcp/command.py | 1 - guillotina/contrib/mcp/interfaces.py | 5 +- guillotina/contrib/mcp/server.py | 1 + guillotina/contrib/mcp/tools.py | 25 +- guillotina/tests/test_ai_query.py | 86 ----- guillotina/tests/test_mcp.py | 6 +- 16 files changed, 30 insertions(+), 1951 deletions(-) delete mode 100644 guillotina/contrib/ai_query/README.md delete mode 100644 guillotina/contrib/ai_query/__init__.py delete mode 100644 guillotina/contrib/ai_query/handler.py delete mode 100644 guillotina/contrib/ai_query/llm_logger.py delete mode 100644 guillotina/contrib/ai_query/prompts.py delete mode 100644 guillotina/contrib/ai_query/providers.py delete mode 100644 guillotina/contrib/ai_query/result_processor.py delete mode 100644 guillotina/contrib/ai_query/schema_analyzer.py delete mode 100644 guillotina/contrib/ai_query/services.py delete mode 100644 guillotina/tests/test_ai_query.py diff --git a/guillotina/contrib/ai_query/README.md b/guillotina/contrib/ai_query/README.md deleted file mode 100644 index 9bff553ab..000000000 --- a/guillotina/contrib/ai_query/README.md +++ /dev/null @@ -1,176 +0,0 @@ -# ai_query - -Contrib to query indexed catalog content in Guillotina using natural language. It translates the question into a structured query, runs the search, and optionally generates a text response. - -## Requirements - -- Guillotina with catalog (e.g. `guillotina.contrib.catalog.pg`) -- **LiteLLM**: `pip install litellm` -- An API key from your chosen LLM provider (OpenAI, Anthropic, Google/Gemini, etc.) - -## Installation - -1. Add the application to your project: - -```yaml -# config.yaml (or your configuration) -applications: - - guillotina.contrib.catalog.pg - - guillotina.contrib.ai_query -``` - -2. Install litellm if you don't have it: - -```bash -pip install litellm -``` - -## Configuration - -Default configuration (you can override in `config.yaml` or environment variables): - -```yaml -ai_query: - enabled: true - provider: openai - model: gpt-4o-mini - api_key: null - base_url: null - max_tokens: 500 - temperature: 0.1 - response_temperature: 0.7 - timeout: 30 - enable_conversation: true - max_conversation_history: 10 - litellm_settings: - retry: - attempts: 3 -``` - -| Option | Description | Default | -|--------|-------------|---------| -| `enabled` | Enable or disable the contrib | `true` | -| `provider` | LiteLLM provider: `openai`, `anthropic`, `azure`, `gemini`, `google`, `ollama` | `openai` | -| `model` | Model name (e.g. `gpt-4o-mini`, `gemini-pro`) | `gpt-4o-mini` | -| `api_key` | API key (optional if set via environment) | `null` | -| `base_url` | Base URL for the API (e.g. proxy or custom endpoint) | `null` | -| `max_tokens` | Maximum tokens per response | `500` | -| `temperature` | Temperature for query translation (0–1) | `0.1` | -| `response_temperature` | Temperature for text response (0–1) | `0.7` | -| `timeout` | Timeout in seconds for LLM calls | `30` | -| `enable_conversation` | Allow sending conversation history in the request body | `true` | -| `max_conversation_history` | Maximum number of history messages considered | `10` | -| `litellm_settings.retry.attempts` | Retries on network/rate limit errors | `3` | - -### API key via environment variable - -If you don't set `api_key` in config, it is read from the environment based on `provider`: - -| provider | Environment variable | -|----------|---------------------| -| openai | `OPENAI_API_KEY` | -| anthropic | `ANTHROPIC_API_KEY` | -| azure | `AZURE_API_KEY` | -| gemini / google | `GEMINI_API_KEY` | -| ollama | (not required) | - -Example with Gemini: - -```yaml -ai_query: - provider: gemini - model: gemini-pro -``` - -And in the environment: `export GEMINI_API_KEY=...` - -## Permissions - -- **Permission:** `guillotina.ai_query.Query` -- **Default:** granted to role `guillotina.Authenticated` - -To restrict who can run queries, change the grants in your `config.yaml` or initialization code. - -## API - -### Endpoint - -`POST /{path_to_container}/@ai-query` - -Example: `POST /db/my-db/@ai-query` - -### Request body (JSON) - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `query` | string | Yes | Natural language question | -| `response_format` | string | No | `natural` (text) or `structured` (search data only). Default: `natural` | -| `stream` | boolean | No | If `true`, response is Server-Sent Events (SSE). Default: `false` | -| `conversation_id` | string | No | Conversation identifier (for context) | -| `context` | array | No | Message history `[{ "role": "user"|"assistant", "content": "..." }]` | - -### Response without stream (`stream: false`) - -- **response_format: natural** - -```json -{ - "answer": "Text generated by the LLM from the results.", - "data": { "items": [...], "items_total": N }, - "conversation_id": "uuid" -} -``` - -- **response_format: structured** - -Returns the search results object directly (no `answer` or `conversation_id`). - -### Response with stream (`stream: true`) - -`Content-Type: text/event-stream` - -SSE events: - -- `data: \n\n` events with chunks of the response. -- A final `event: done` with `data: {"data": {...}, "conversation_id": "..."}` (JSON). -- On stream error: `event: error` with `data: {"error": "..."}`. - -### Example (without stream) - -```bash -curl -X POST "http://localhost:8080/db/container/@ai-query" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{"query": "How many documents are there?"}' -``` - -### Example (with stream) - -```bash -curl -X POST "http://localhost:8080/db/container/@ai-query" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{"query": "How many documents are there?", "stream": true}' -``` - -### Example with conversation - -```json -{ - "query": "And the Folder type?", - "conversation_id": "conv-123", - "context": [ - { "role": "user", "content": "How many items are there?" }, - { "role": "assistant", "content": "There are 42 items." } - ] -} -``` - -## How it works - -1. The catalog schema (content types and indexed fields) is discovered for the container where `@ai-query` is called. -2. The natural language question is translated into a JSON query (type, filters, `_metadata`, `_size`, etc.) via the LLM. -3. The query is executed against Guillotina's catalog. -4. If a natural language response was requested, results are passed to the LLM to generate the text (or streamed when `stream: true`). - -Aggregations (sum, count, average) are computed in memory over the search results; Guillotina's catalog does not receive an aggregation parameter. diff --git a/guillotina/contrib/ai_query/__init__.py b/guillotina/contrib/ai_query/__init__.py deleted file mode 100644 index 6ad854a04..000000000 --- a/guillotina/contrib/ai_query/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from guillotina import configure - - -configure.permission("guillotina.ai_query.Query", "Query data using natural language") -configure.grant(permission="guillotina.ai_query.Query", role="guillotina.Authenticated") - - -app_settings = { - "ai_query": { - "enabled": True, - "provider": "openai", - "model": "gpt-4o-mini", - "api_key": None, - "base_url": None, - "max_tokens": 500, - "query_translation_max_tokens": 1024, - "temperature": 0.1, - "response_temperature": 0.7, - "timeout": 30, - "enable_conversation": True, - "max_conversation_history": 10, - "max_steps": 5, - "retry_on_empty": True, - "log_llm_interactions": False, - "log_llm_dir": None, - "litellm_settings": { - "retry": {"attempts": 3}, - }, - } -} - - -def includeme(root, settings): - configure.scan("guillotina.contrib.ai_query.services") diff --git a/guillotina/contrib/ai_query/handler.py b/guillotina/contrib/ai_query/handler.py deleted file mode 100644 index 34ac9f558..000000000 --- a/guillotina/contrib/ai_query/handler.py +++ /dev/null @@ -1,365 +0,0 @@ -from guillotina import app_settings -from guillotina.contrib.ai_query.llm_logger import LLMInteractionLogger -from guillotina.contrib.ai_query.prompts import PromptBuilder -from guillotina.contrib.ai_query.providers import LLMProvider -from guillotina.contrib.ai_query.schema_analyzer import SchemaAnalyzer -from guillotina.interfaces import IResource -from typing import AsyncGenerator -from typing import Dict -from typing import List -from typing import Optional - -import json -import logging -import time - - -logger = logging.getLogger("guillotina") - - -def _looks_truncated(text: str) -> bool: - """Heuristic: response likely cut off before completing JSON.""" - if not text or len(text) < 10: - return True - stripped = text.strip() - if stripped.endswith(",") or stripped.endswith(":") or stripped.endswith('"'): - return True - open_braces = stripped.count("{") - stripped.count("}") - open_brackets = stripped.count("[") - stripped.count("]") - return open_braces > 0 or open_brackets > 0 - - -class AIQueryHandler: - """ - Handler for translating natural language queries and generating responses. - """ - - def __init__(self): - self.provider = LLMProvider() - - @property - def settings(self): - return app_settings.get("ai_query", {}) - - async def get_schema_info(self, context: IResource) -> dict: - """Get schema information for the context.""" - analyzer = SchemaAnalyzer(context) - return await analyzer.get_schema_info() - - async def translate_query( - self, - natural_language: str, - context: IResource, - schema_info: dict, - conversation_history: Optional[List[Dict]] = None, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> dict: - """ - Translate natural language query to structured query format. - """ - if not self.provider.is_enabled(): - raise ValueError("AI query is not enabled") - - system_prompt, user_prompt_template = PromptBuilder.build_query_translation_prompt( - schema_info, conversation_history - ) - user_prompt = user_prompt_template.format(query=natural_language) - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - temperature = self.settings.get("temperature", 0.1) - max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) - timeout = self.settings.get("timeout", 30) - - try: - t0 = time.perf_counter() - response_text = await self.provider.completion( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - ) - duration = time.perf_counter() - t0 - response = self._parse_query_response(response_text) - if interaction_logger: - interaction_logger.log_translate_query( - messages, response_text, response, duration_seconds=duration - ) - if self._is_step_response(response): - self._validate_query(response["query"], schema_info) - return response - self._validate_query(response, schema_info) - return response - except Exception as e: - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_llm_error( - "translate_query", e, messages=messages, duration_seconds=duration - ) - logger.error("Query translation failed: %s", e, exc_info=True) - raise - - async def translate_next_step( - self, - natural_language: str, - context: IResource, - schema_info: dict, - step_results: List[Dict], - conversation_history: Optional[List[Dict]] = None, - interaction_logger: Optional[LLMInteractionLogger] = None, - step_index: int = 1, - ) -> dict: - """ - Given previous step results, return either the next query to run - (with _next: true) or _action: answer. Used in multi-step agent loop. - """ - if not self.provider.is_enabled(): - raise ValueError("AI query is not enabled") - - system_prompt, user_prompt_template = PromptBuilder.build_next_step_prompt( - schema_info, step_results, conversation_history - ) - step_results_desc = PromptBuilder.format_step_results(step_results) - user_prompt = user_prompt_template.format(query=natural_language, step_results=step_results_desc) - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - temperature = self.settings.get("temperature", 0.1) - max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) - timeout = self.settings.get("timeout", 30) - - try: - t0 = time.perf_counter() - response_text = await self.provider.completion( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - ) - duration = time.perf_counter() - t0 - response = self._parse_query_response(response_text) - if interaction_logger: - interaction_logger.log_translate_next_step( - step_index, messages, response_text, response, duration_seconds=duration - ) - if response.get("_action") == "answer": - return response - if self._is_step_response(response): - self._validate_query(response["query"], schema_info) - return response - raise ValueError("Expected next-step query or _action answer from LLM") - except Exception as e: - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_llm_error( - f"translate_next_step (step {step_index})", - e, - messages=messages, - duration_seconds=duration, - ) - logger.error("Next step translation failed: %s", e, exc_info=True) - raise - - def _is_step_response(self, response: dict) -> bool: - """True if response is a step (has query and _next).""" - return isinstance(response.get("query"), dict) and response.get("_next") is True - - async def translate_retry_on_empty( - self, - natural_language: str, - context: IResource, - schema_info: dict, - last_query: dict, - conversation_history: Optional[List[Dict]] = None, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> dict: - """ - When the last query returned 0 results, ask the LLM for one alternative - query or to confirm no results. Returns {"query": {...}, "_next": true} - or {"_action": "answer"}. - """ - if not self.provider.is_enabled(): - raise ValueError("AI query is not enabled") - - t0 = time.perf_counter() - system_prompt, user_prompt_template = PromptBuilder.build_retry_on_empty_prompt( - schema_info, natural_language, last_query, conversation_history - ) - user_prompt = user_prompt_template.format( - user_query=natural_language, - last_query=json.dumps(last_query, indent=2, default=str), - ) - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - temperature = self.settings.get("temperature", 0.1) - max_tokens = self.settings.get("query_translation_max_tokens", self.settings.get("max_tokens", 1024)) - timeout = self.settings.get("timeout", 30) - - try: - response_text = await self.provider.completion( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - ) - duration = time.perf_counter() - t0 - response = self._parse_query_response(response_text) - if interaction_logger: - interaction_logger.log_retry_on_empty( - messages, response_text, response, duration_seconds=duration - ) - if response.get("_action") == "answer": - return response - if self._is_step_response(response): - self._validate_query(response["query"], schema_info) - return response - raise ValueError("Expected alternative query or _action answer from LLM") - except Exception as e: - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_llm_error( - "retry_on_empty", e, messages=messages, duration_seconds=duration - ) - logger.error("Retry on empty failed: %s", e, exc_info=True) - raise - - async def generate_response( - self, - query: str, - results: dict, - schema_info: dict, - conversation_history: Optional[List[Dict]] = None, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> str: - """ - Generate natural language response from query results. - """ - if not self.provider.is_enabled(): - return "AI query is not enabled" - - messages = PromptBuilder.build_response_generation_prompt( - query, results, schema_info, conversation_history - ) - - temperature = self.settings.get("response_temperature", 0.7) - max_tokens = self.settings.get("max_tokens", 500) - timeout = self.settings.get("timeout", 30) - - try: - t0 = time.perf_counter() - response = await self.provider.completion( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - ) - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_generate_response(messages, response, duration_seconds=duration) - return response - except Exception as e: - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_llm_error( - "generate_response", e, messages=messages, duration_seconds=duration - ) - logger.error("Response generation failed: %s", e, exc_info=True) - return f"Error generating response: {str(e)}" - - async def generate_response_stream( - self, - query: str, - results: dict, - schema_info: dict, - conversation_history: Optional[List[Dict]] = None, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> AsyncGenerator[str, None]: - """ - Generate natural language response from query results; yields content chunks. - """ - if not self.provider.is_enabled(): - yield "AI query is not enabled" - return - - messages = PromptBuilder.build_response_generation_prompt( - query, results, schema_info, conversation_history - ) - - temperature = self.settings.get("response_temperature", 0.7) - max_tokens = self.settings.get("max_tokens", 500) - timeout = self.settings.get("timeout", 30) - - try: - t0 = time.perf_counter() - chunks = [] - async for chunk in self.provider.completion_stream( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - ): - chunks.append(chunk) - yield chunk - duration = time.perf_counter() - t0 - if interaction_logger and chunks: - interaction_logger.log_generate_response(messages, "".join(chunks), duration_seconds=duration) - except Exception as e: - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_llm_error( - "generate_response_stream", e, messages=messages, duration_seconds=duration - ) - logger.error("Response stream failed: %s", e, exc_info=True) - yield f"Error generating response: {str(e)}" - - def _parse_query_response(self, response_text: str) -> dict: - """Parse LLM response text into query dict.""" - response_text = response_text.strip() - - if response_text.startswith("```json"): - response_text = response_text[7:] - if response_text.startswith("```"): - response_text = response_text[3:] - if response_text.endswith("```"): - response_text = response_text[:-3] - response_text = response_text.strip() - - try: - return json.loads(response_text) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse query response: {response_text}", exc_info=True) - if _looks_truncated(response_text): - raise ValueError( - "LLM response was truncated (incomplete JSON). " - "Increase ai_query.query_translation_max_tokens in settings." - ) from e - raise ValueError(f"Invalid query format: {e}") from e - - def _validate_query(self, query: dict, schema_info: dict): - """Validate translated query against discovered schema.""" - if not isinstance(query, dict): - raise ValueError("Query must be a dictionary") - - type_name = query.get("type_name") - if type_name: - if type_name not in schema_info.get("content_types", {}): - raise ValueError(f"Unknown content type: {type_name}") - - content_types = schema_info.get("content_types", {}) - for key, value in query.items(): - if key.startswith("_") or key == "type_name" or key == "aggregation": - continue - - field_name = key.split("__")[0] - if type_name and type_name in content_types: - if field_name not in content_types[type_name]: - logger.warning(f"Field {field_name} not found in {type_name}, but allowing query") diff --git a/guillotina/contrib/ai_query/llm_logger.py b/guillotina/contrib/ai_query/llm_logger.py deleted file mode 100644 index cf5553b4c..000000000 --- a/guillotina/contrib/ai_query/llm_logger.py +++ /dev/null @@ -1,223 +0,0 @@ -from datetime import datetime -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -import json -import os - - -def _duration_line(seconds: Optional[float]) -> str: - if seconds is None: - return "" - return f" duration: {round(seconds * 1000)}ms\n" - - -def _summary(obj: Any, max_items: int = 10) -> str: - if isinstance(obj, dict): - if "items" in obj and isinstance(obj["items"], list): - total = obj.get("items_total", len(obj["items"])) - n = len(obj["items"]) - return f"items={n}, items_total={total}" - return json.dumps(obj, indent=2, default=str) - if isinstance(obj, list): - if len(obj) > max_items: - return ( - json.dumps(obj[:max_items], indent=2, default=str) + f"\n... and {len(obj) - max_items} more" - ) - return json.dumps(obj, indent=2, default=str) - return str(obj) - - -def format_result_summary(result: Dict) -> str: - """One-line summary of a search/aggregation result for step execution log.""" - if not result: - return "empty" - if "items" in result: - n = len(result.get("items", [])) - total = result.get("items_total", n) - return f"items={n}, items_total={total}" - if any(k in result for k in ("total", "count", "average", "overall_average")): - parts = [ - f"{k}={v}" for k, v in result.items() if k in ("total", "count", "average", "overall_average") - ] - return ", ".join(parts) - return _summary(result, max_items=3) - - -def _format_catalog_result(result: Dict, max_items: int = 50) -> str: - """Format catalog result for log: full if small, else summary + sample of items.""" - if not result: - return "empty" - if "items" in result and isinstance(result.get("items"), list): - items = result["items"] - total = result.get("items_total", len(items)) - if len(items) <= max_items: - return json.dumps(result, indent=2, default=str) - sample = {"items": items[:max_items], "items_total": total} - return json.dumps(sample, indent=2, default=str) + f"\n... and {len(items) - max_items} more items" - return json.dumps(result, indent=2, default=str) - - -class LLMInteractionLogger: - """ - Logs all LLM interactions for a single request to a file (one file per request). - Enable via ai_query.log_llm_interactions and set ai_query.log_llm_dir. - """ - - def __init__( - self, - request_id: str, - enabled: bool, - log_dir: Optional[str] = None, - ): - self.request_id = request_id - self.enabled = enabled and bool(log_dir) - self._path: Optional[str] = None - self._file = None - if self.enabled and log_dir: - os.makedirs(log_dir, exist_ok=True) - ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - self._path = os.path.join(log_dir, f"{ts}_{request_id[:8]}.log") - self._file = open(self._path, "w", encoding="utf-8") - self._write(f"=== LLM interaction log ===\nrequest_id: {request_id}\n") - - def _write(self, text: str) -> None: - if self._file: - self._file.write(text) - self._file.flush() - - def close(self) -> None: - if self._file: - try: - self._file.close() - except Exception: - pass - self._file = None - - def log_request_start(self, query_text: str) -> None: - if not self.enabled: - return - self._write(f"\n--- Request start ---\nquery: {query_text}\n") - - def log_translate_query( - self, - messages: List[Dict], - response_text: str, - parsed: Dict, - duration_seconds: Optional[float] = None, - ) -> None: - if not self.enabled: - return - self._write("\n--- translate_query (natural language -> structured query) ---\n") - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - self._write(f" [{role}]:\n{content}\n") - self._write(f" [LLM response]:\n{response_text}\n") - self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") - self._write(_duration_line(duration_seconds)) - - def log_translate_next_step( - self, - step_index: int, - messages: List[Dict], - response_text: str, - parsed: Dict, - duration_seconds: Optional[float] = None, - ) -> None: - if not self.enabled: - return - self._write(f"\n--- translate_next_step (step {step_index}) ---\n") - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - self._write(f" [{role}]:\n{content}\n") - self._write(f" [LLM response]:\n{response_text}\n") - self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") - self._write(_duration_line(duration_seconds)) - - def log_catalog_search( - self, - query: Dict, - result: Dict, - step_index: Optional[int] = None, - duration_seconds: Optional[float] = None, - ) -> None: - """Log a Guillotina catalog search: query sent and response received.""" - if not self.enabled: - return - if step_index is not None: - self._write(f"\n--- catalog search (step {step_index}) ---\n") - else: - self._write("\n--- catalog search ---\n") - self._write(f" query:\n{json.dumps(query, indent=2, default=str)}\n") - self._write(f" response:\n{_format_catalog_result(result)}\n") - self._write(_duration_line(duration_seconds)) - - def log_step_execution(self, step_index: int, query: Dict, result_summary: str) -> None: - if not self.enabled: - return - self._write(f"\n--- step {step_index} execution ---\n") - self._write(f" query: {json.dumps(query, indent=2, default=str)}\n") - self._write(f" result summary: {result_summary}\n") - - def log_generate_response( - self, - messages: List[Dict], - response_text: str, - duration_seconds: Optional[float] = None, - ) -> None: - if not self.enabled: - return - self._write("\n--- generate_response (results -> natural language) ---\n") - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - self._write(f" [{role}]:\n{content}\n") - self._write(f" [LLM response]:\n{response_text}\n") - self._write(_duration_line(duration_seconds)) - - def log_retry_on_empty( - self, - messages: List[Dict], - response_text: str, - parsed: Dict, - duration_seconds: Optional[float] = None, - ) -> None: - """Log the retry-on-empty LLM call (alternative query or _action answer).""" - if not self.enabled: - return - self._write("\n--- retry_on_empty (0 results, suggest alternative or confirm) ---\n") - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - self._write(f" [{role}]:\n{content}\n") - self._write(f" [LLM response]:\n{response_text}\n") - self._write(f" [parsed]:\n{json.dumps(parsed, indent=2, default=str)}\n") - self._write(_duration_line(duration_seconds)) - - def log_llm_error( - self, - step_name: str, - error: Exception, - messages: Optional[List[Dict]] = None, - duration_seconds: Optional[float] = None, - ) -> None: - """Log an LLM call failure (e.g. timeout, API error).""" - if not self.enabled: - return - self._write(f"\n--- {step_name} FAILED ---\n") - self._write(f" error: {type(error).__name__}: {error}\n") - self._write(_duration_line(duration_seconds)) - if messages: - for m in messages: - role = m.get("role", "") - content = m.get("content", "") - self._write(f" [request {role}]:\n{content}\n") - - def log_request_end(self) -> None: - if not self.enabled: - return - self._write("\n--- Request end ---\n") diff --git a/guillotina/contrib/ai_query/prompts.py b/guillotina/contrib/ai_query/prompts.py deleted file mode 100644 index 23ea62952..000000000 --- a/guillotina/contrib/ai_query/prompts.py +++ /dev/null @@ -1,318 +0,0 @@ -from typing import Dict -from typing import List -from typing import Optional -from typing import Tuple - - -class PromptBuilder: - @staticmethod - def build_query_translation_prompt( - schema_info: Dict, - conversation_history: Optional[List[Dict]] = None, - ) -> Tuple[str, str]: - """ - Build prompt for translating natural language to structured query. - """ - content_types_desc = PromptBuilder._format_content_types(schema_info) - field_types_desc = PromptBuilder._format_field_types(schema_info) - request_context_desc = PromptBuilder._format_request_context(schema_info) - - system_prompt = """You are a query translation assistant for Guillotina, a content management system. -Your task is to translate natural language queries into structured JSON queries that match Guillotina's search syntax. - -Current request context (the resource where the query is being run): -{request_context} - -Use this context when the user refers to "this" container, "here", or the current resource: set `path__starts` to request_context.path or `id__eq` to request_context.id so the search is scoped to the current context. -When the user refers to something by name or slug, resolve it via the schema: use the appropriate content type and filter by `title__in` or `title__eq` for names/titles, or `id__eq` for ids. - -Available content types and their indexed fields: -{content_types} - -Field type categories: -{field_types} - -Query syntax rules: -- Use `type_name` to filter by content type (e.g., "type_name": "Document") -- Field filters use format: `field_name__operator` (e.g., "title__in": "search term") -- Available operators: - - `__eq`: exact match - - `__in`: contains (for text fields) - - `__not`: does not contain - - `__gt`, `__gte`, `__lt`, `__lte`: comparisons (for numeric/date fields) - - `__wildcard`: wildcard pattern matching - - `path__starts`: filter by path prefix (use request_context.path for current container) -- Use `_metadata` to specify which fields to return (comma-separated) -- Use `_size` to limit results (max 50) -- Use `_from` for pagination -- Use `_sort_asc` or `_sort_des` for sorting -- For aggregations (sum, count, average) that must consider ALL matching items, set `_collect_all`: true so the system will paginate and aggregate over the full result set. - -Date handling: -- Relative dates: convert to current week/month and then to ISO format "YYYY-MM-DDTHH:MM:SSZ" - -Return ONLY valid JSON. Either a single query object, or a multi-step plan: - -Single query (use when one search is enough): -{{ - "type_name": "ContentTypeName", - "field_name__operator": "value", - "_metadata": "field1,field2", - "_size": 20 -}} - -Multi-step (use when you must first resolve a resource by name/title, then run another query using its path or id): -{{ - "query": {{ "type_name": "...", "title__in": "name to resolve", "_size": 1, "_metadata": "path,id,title" }}, - "_next": true -}} -The system will run the query, then ask you for the next step with the result. You will then return either the next {{ "query": {{ ... }}, "_next": true }} (use path or id from the first item of the previous result to set path__starts or a filter) or {{ "_action": "answer" }} when you have enough to answer. - -If the query asks for aggregations (sum, count, average), include an "aggregation" field and set "_collect_all": true so all matching items are fetched before aggregating: -{{ - "type_name": "ContentTypeName", - "field_name__operator": "value", - "_metadata": "field1,field2", - "_collect_all": true, - "aggregation": {{ - "operation": "sum|count|average", - "field": "numeric_field_name", - "group_by": "optional_grouping_field" - }} -}} -""" - - user_prompt_template = """Translate the following natural language query to a structured JSON query. -Use the discovered schema to map natural language terms to actual field names. -If the query references previous context, use the conversation history to understand what was asked before. - -Query: {query} - -Return only the JSON query, no additional text.""" - - if conversation_history: - history_text = "\n".join( - [f"{msg['role']}: {msg['content']}" for msg in conversation_history[-5:]] - ) - user_prompt_template = f"""Previous conversation: -{history_text} - -{user_prompt_template}""" - - system_prompt_formatted = system_prompt.format( - request_context=request_context_desc, - content_types=content_types_desc, - field_types=field_types_desc, - ) - - return system_prompt_formatted, user_prompt_template - - @staticmethod - def build_response_generation_prompt( - query: str, - results: Dict, - schema_info: Dict, - conversation_history: Optional[List[Dict]] = None, - ) -> List[Dict]: - """ - Build messages for generating natural language response from results. - """ - system_prompt = """You are a helpful assistant that answers questions about data in Guillotina. -You receive query results and must provide a clear, natural language response in the same language as the query. - -Guidelines: -- Be concise but informative -- Use the actual field names and values from the results -- If aggregations were performed, explain the calculations -- If no results found, explain why -- Maintain conversation context if provided -- Use the same language as the user's query (Catalan, Spanish, English, etc.) -""" - - results_summary = PromptBuilder._format_results(results) - - user_content = f"""Query: {query} - -Results: -{results_summary} - -Provide a natural language answer based on these results.""" - - messages = [{"role": "system", "content": system_prompt}] - - if conversation_history: - for msg in conversation_history[-3:]: - messages.append(msg) - - messages.append({"role": "user", "content": user_content}) - - return messages - - @staticmethod - def build_next_step_prompt( - schema_info: Dict, - step_results: List[Dict], - conversation_history: Optional[List[Dict]] = None, - ) -> tuple: - """ - Build prompt for the next step in a multi-step plan: given previous - step results, return either the next query or _action answer. - """ - content_types_desc = PromptBuilder._format_content_types(schema_info) - - system_prompt = """You are the next-step planner for a multi-step query in Guillotina. -The user asked a question that required running one or more searches. Previous step(s) have been run; below are their results. - -Available content types and indexed fields: -{content_types} - -Your response must be exactly one of: - -1) Next query to run (use path, id, or other fields from the previous step result to scope the search): -{{ "query": {{ "type_name": "...", "path__starts": "", ... }}, "_next": true }} - -2) Enough data to answer (no more queries): -{{ "_action": "answer" }} - -Use the first item from the previous step result (e.g. its "path" or "id") to set path__starts or filters for the next query. Return only valid JSON.""" - - user_prompt_template = """User question: {query} - -Previous step(s) results: -{step_results} - -Return either the next query JSON with "_next": true, or {{ "_action": "answer" }}. No other text.""" - - if conversation_history: - history_text = "\n".join(f"{m['role']}: {m['content']}" for m in conversation_history[-3:]) - user_prompt_template = f"Previous conversation:\n{history_text}\n\n{user_prompt_template}" - - system_formatted = system_prompt.format(content_types=content_types_desc) - return system_formatted, user_prompt_template - - @staticmethod - def build_retry_on_empty_prompt( - schema_info: Dict, - user_query: str, - last_query: Dict, - conversation_history: Optional[List[Dict]] = None, - ) -> Tuple[str, str]: - """ - Build prompt for retry when the last query returned 0 results: suggest - one alternative query or confirm no results. - """ - content_types_desc = PromptBuilder._format_content_types(schema_info) - request_context_desc = PromptBuilder._format_request_context(schema_info) - - system_prompt = """You are a query assistant for Guillotina. The last catalog query returned zero results. -Either the data does not exist, or the query was too strict (wrong type, field, or operator). - -Current request context: -{request_context} - -Available content types and indexed fields: -{content_types} - -You must return exactly one of: - -1) One alternative query to try (e.g. different type_name, use title__wildcard instead of title__in, or id__eq instead of title, or looser path): -{{ "query": {{ "type_name": "...", ... }}, "_next": true }} - -2) Confirm there are no results (do not retry): -{{ "_action": "answer" }} - -Return only valid JSON. No other text.""" - - user_prompt_template = """User question: {user_query} - -Last query (returned 0 results): -{last_query} - -Return either one alternative query with "_next": true, or {{ "_action": "answer" }}.""" - - if conversation_history: - history_text = "\n".join(f"{m['role']}: {m['content']}" for m in conversation_history[-3:]) - user_prompt_template = f"Previous conversation:\n{history_text}\n\n{user_prompt_template}" - - system_formatted = system_prompt.format( - request_context=request_context_desc, - content_types=content_types_desc, - ) - return system_formatted, user_prompt_template - - @staticmethod - def format_step_results(step_results: List[Dict]) -> str: - """Format step results for the next-step prompt.""" - parts = [] - for i, step in enumerate(step_results, 1): - q = step.get("query", {}) - res = step.get("result", {}) - items = res.get("items", []) - total = res.get("items_total", len(items)) - parts.append(f"Step {i} query: {q}") - if total == 0: - parts.append(f"Step {i} result: no items") - elif total <= 3: - parts.append(f"Step {i} result ({total} items): {items}") - else: - parts.append(f"Step {i} result ({total} items, first 3): {items[:3]}") - return "\n".join(parts) - - @staticmethod - def _format_content_types(schema_info: Dict) -> str: - """Format content types and fields for prompt.""" - lines = [] - for type_name, fields in schema_info.get("content_types", {}).items(): - field_list = ", ".join([f"{k} ({v})" for k, v in fields.items()]) - lines.append(f"- {type_name}: {field_list}") - return "\n".join(lines) if lines else "No content types found" - - @staticmethod - def _format_request_context(schema_info: Dict) -> str: - """Format request context (path, id, type_name, title) for prompt.""" - ctx = schema_info.get("request_context", {}) - if not ctx: - return "Not available" - parts = [f"path: {ctx.get('path', '')}", f"id: {ctx.get('id', '')}"] - if ctx.get("type_name"): - parts.append(f"type_name: {ctx['type_name']}") - if ctx.get("title") is not None: - parts.append(f"title: {ctx['title']}") - return ", ".join(parts) - - @staticmethod - def _format_field_types(schema_info: Dict) -> str: - """Format field type categories for prompt.""" - field_types = schema_info.get("field_types", {}) - lines = [] - for category, fields in field_types.items(): - if fields: - lines.append(f"- {category}: {', '.join(fields[:10])}") - return "\n".join(lines) if lines else "No field types found" - - @staticmethod - def _format_results(results: Dict) -> str: - """Format query results for response generation.""" - if "items" not in results and any( - k in results for k in ("count", "total", "average", "overall_average") - ): - parts = [f"{k} = {v}" for k, v in results.items() if not k.startswith("by_")] - by_parts = [f"{k} = {v}" for k, v in results.items() if k.startswith("by_")] - if by_parts: - parts.extend(by_parts) - return "Aggregation result: " + ", ".join(parts) - if "items" not in results: - return "No results found" - - items = results.get("items", []) - total = results.get("items_total", len(items)) - - if total == 0: - return "No results found matching the query." - - if total <= 5: - items_text = "\n".join([f"- {item}" for item in items]) - return f"Found {total} result(s):\n{items_text}" - else: - sample = "\n".join([f"- {item}" for item in items[:5]]) - return f"Found {total} result(s). Showing first 5:\n{sample}\n... and {total - 5} more" diff --git a/guillotina/contrib/ai_query/providers.py b/guillotina/contrib/ai_query/providers.py deleted file mode 100644 index e45593aa0..000000000 --- a/guillotina/contrib/ai_query/providers.py +++ /dev/null @@ -1,152 +0,0 @@ -from guillotina import app_settings -from typing import Dict -from typing import List -from typing import Optional - -import logging -import os - - -logger = logging.getLogger("guillotina") - - -class LLMProvider: - """ - Wrapper around LiteLLM for Guillotina-specific needs. - Handles configuration, error handling, and provider management. - """ - - def __init__(self): - self._check_litellm_available() - - @property - def settings(self): - return app_settings.get("ai_query", {}) - - def _check_litellm_available(self): - """Check if LiteLLM is available, raise helpful error if not.""" - try: - import litellm - self.litellm = litellm - except ImportError: - raise ImportError( - "LiteLLM is required for ai_query. Install it with: pip install litellm" - ) - - def get_model_name(self) -> str: - """Get the full model name in LiteLLM format.""" - provider = self.settings.get("provider", "openai") - model = self.settings.get("model", "gpt-4o-mini") - - if "/" in model: - return model - return f"{provider}/{model}" - - def get_api_key(self) -> Optional[str]: - """Get API key from settings or environment.""" - api_key = self.settings.get("api_key") - if api_key: - return api_key - - provider = self.settings.get("provider", "openai") - - env_var_map = { - "openai": "OPENAI_API_KEY", - "anthropic": "ANTHROPIC_API_KEY", - "azure": "AZURE_API_KEY", - "gemini": "GEMINI_API_KEY", - "google": "GEMINI_API_KEY", - "ollama": None, - } - - env_var = env_var_map.get(provider) - if env_var: - return os.getenv(env_var) - - return None - - def _build_completion_kwargs( - self, - messages: List[Dict], - temperature: float = 0.1, - max_tokens: int = 500, - timeout: int = 30, - stream: bool = False, - ) -> dict: - """Build kwargs for LiteLLM acompletion.""" - kwargs = { - "model": self.get_model_name(), - "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, - "timeout": timeout, - "stream": stream, - } - api_key = self.get_api_key() - if api_key: - kwargs["api_key"] = api_key - base_url = self.settings.get("base_url") - if base_url: - kwargs["api_base"] = base_url - retry_config = self.settings.get("litellm_settings", {}).get("retry", {}) - if retry_config.get("attempts", 0) > 0: - kwargs["num_retries"] = retry_config["attempts"] - return kwargs - - def _handle_completion_error(self, e: Exception) -> None: - """Map LLM errors to user-facing ValueError.""" - error_msg = str(e).lower() - if "rate limit" in error_msg or "429" in error_msg: - logger.error(f"LLM rate limit error: {e}") - raise ValueError("Rate limit exceeded. Please try again later.") - if "timeout" in error_msg or "timed out" in error_msg: - logger.error(f"LLM timeout error: {e}") - raise ValueError("Request timed out. Please try again.") - logger.error(f"LLM provider error: {e}", exc_info=True) - raise ValueError(f"LLM provider error: {str(e)}") - - async def completion( - self, - messages: List[Dict], - temperature: float = 0.1, - max_tokens: int = 500, - timeout: int = 30, - ) -> str: - """Call LLM provider using LiteLLM unified interface.""" - kwargs = self._build_completion_kwargs( - messages, temperature, max_tokens, timeout, stream=False - ) - try: - response = await self.litellm.acompletion(**kwargs) - if not response or not response.choices: - raise ValueError("Empty response from LLM provider") - return response.choices[0].message.content - except Exception as e: - self._handle_completion_error(e) - - async def completion_stream( - self, - messages: List[Dict], - temperature: float = 0.1, - max_tokens: int = 500, - timeout: int = 30, - ): - """Call LLM provider with stream=True; yields content chunks (str).""" - kwargs = self._build_completion_kwargs( - messages, temperature, max_tokens, timeout, stream=True - ) - try: - stream = await self.litellm.acompletion(**kwargs) - if stream is None: - raise ValueError("Empty stream from LLM provider") - async for chunk in stream: - if chunk.choices and len(chunk.choices) > 0: - delta = chunk.choices[0].delta - if hasattr(delta, "content") and delta.content: - yield delta.content - except Exception as e: - self._handle_completion_error(e) - - def is_enabled(self) -> bool: - """Check if AI query is enabled.""" - return self.settings.get("enabled", True) diff --git a/guillotina/contrib/ai_query/result_processor.py b/guillotina/contrib/ai_query/result_processor.py deleted file mode 100644 index 11d5f578e..000000000 --- a/guillotina/contrib/ai_query/result_processor.py +++ /dev/null @@ -1,112 +0,0 @@ -from typing import Dict -from typing import List -from typing import Optional - -import logging - - -logger = logging.getLogger("guillotina") - - -class ResultProcessor: - """ - Process and aggregate search results generically. - """ - - @staticmethod - def process_results( - results: Dict, - aggregation_config: Optional[Dict] = None, - ) -> Dict: - """ - Process search results and apply aggregations if needed. - """ - if not aggregation_config: - return results - - items = results.get("items", []) - items_total = results.get("items_total") - operation = aggregation_config.get("operation") - field = aggregation_config.get("field") - group_by = aggregation_config.get("group_by") - - if operation == "count" and not group_by: - total = items_total if items_total is not None else len(items) - return {"count": total} - if not items: - return results - - if operation == "sum": - return ResultProcessor._sum_aggregation(items, field, group_by) - elif operation == "count": - return ResultProcessor._count_aggregation(items, field, group_by) - elif operation == "average": - return ResultProcessor._average_aggregation(items, field, group_by) - else: - logger.warning(f"Unknown aggregation operation: {operation}") - return results - - @staticmethod - def _sum_aggregation( - items: List[Dict], field: str, group_by: Optional[str] = None - ) -> Dict: - """Sum numeric field values, optionally grouped by another field.""" - if group_by: - grouped = {} - for item in items: - group_key = item.get(group_by, "unknown") - value = ResultProcessor._get_numeric_value(item, field) - grouped[group_key] = grouped.get(group_key, 0) + value - return {"by_" + group_by: grouped, "total": sum(grouped.values())} - else: - total = sum(ResultProcessor._get_numeric_value(item, field) for item in items) - return {"total": total, "items_count": len(items)} - - @staticmethod - def _count_aggregation( - items: List[Dict], field: Optional[str] = None, group_by: Optional[str] = None - ) -> Dict: - """Count items, optionally grouped by field.""" - if group_by: - grouped = {} - for item in items: - group_key = item.get(group_by, "unknown") - grouped[group_key] = grouped.get(group_key, 0) + 1 - return {"by_" + group_by: grouped, "total": len(items)} - else: - return {"count": len(items)} - - @staticmethod - def _average_aggregation( - items: List[Dict], field: str, group_by: Optional[str] = None - ) -> Dict: - """Calculate average of numeric field, optionally grouped.""" - if group_by: - grouped = {} - counts = {} - for item in items: - group_key = item.get(group_by, "unknown") - value = ResultProcessor._get_numeric_value(item, field) - grouped[group_key] = grouped.get(group_key, 0) + value - counts[group_key] = counts.get(group_key, 0) + 1 - - averages = { - k: v / counts[k] if counts[k] > 0 else 0 - for k, v in grouped.items() - } - return {"by_" + group_by: averages, "overall_average": sum(grouped.values()) / len(items) if items else 0} - else: - total = sum(ResultProcessor._get_numeric_value(item, field) for item in items) - return {"average": total / len(items) if items else 0, "items_count": len(items)} - - @staticmethod - def _get_numeric_value(item: Dict, field: str) -> float: - """Extract numeric value from item, handling nested fields.""" - value = item.get(field, 0) - if isinstance(value, (int, float)): - return float(value) - try: - return float(value) - except (ValueError, TypeError): - logger.warning(f"Could not convert {field} to numeric: {value}") - return 0.0 diff --git a/guillotina/contrib/ai_query/schema_analyzer.py b/guillotina/contrib/ai_query/schema_analyzer.py deleted file mode 100644 index d39fd92e4..000000000 --- a/guillotina/contrib/ai_query/schema_analyzer.py +++ /dev/null @@ -1,117 +0,0 @@ -from guillotina.component import get_utilities_for -from guillotina.content import get_all_possible_schemas_for_type -from guillotina.directives import index -from guillotina.directives import merged_tagged_value_dict -from guillotina.interfaces import IBehavior -from guillotina.interfaces import IResource -from guillotina.interfaces import IResourceFactory -from guillotina.utils import get_content_path - -import logging - - -logger = logging.getLogger("guillotina") - - -class SchemaAnalyzer: - def __init__(self, context: IResource): - self.context = context - - def get_request_context(self) -> dict: - """ - Return current request context (path, id, type_name, title) so the LLM - can filter by "this container" or resolve references by id/title. - """ - path = get_content_path(self.context) - if path != "/" and not path.endswith("/"): - path = path + "/" - ctx = { - "path": path, - "id": getattr(self.context, "__name__", None) or getattr(self.context, "id", None), - } - type_name = getattr(self.context, "type_name", None) - if type_name: - ctx["type_name"] = type_name - title = getattr(self.context, "title", None) - if title is not None: - ctx["title"] = title - return ctx - - async def get_schema_info(self) -> dict: - """ - Discover all content types, indexed fields, and field types dynamically. - Returns a dictionary with schema information. - """ - schema_info = { - "content_types": {}, - "behaviors": {}, - "field_types": {}, - } - - factories = list(get_utilities_for(IResourceFactory)) - logger.debug(f"Discovered {len(factories)} content type factories") - - for factory_name, factory in factories: - type_name = factory.type_name - if type_name is None: - continue - - type_schema = {} - schemas = get_all_possible_schemas_for_type(type_name) - - for schema in schemas: - indices = merged_tagged_value_dict(schema, index.key) - for field_name, index_info in indices.items(): - field_type = index_info.get("type", "text") - type_schema[field_name] = field_type - - if type_schema: - schema_info["content_types"][type_name] = type_schema - - behaviors = list(get_utilities_for(IBehavior)) - logger.debug(f"Discovered {len(behaviors)} behaviors") - - for behavior_name, behavior_utility in behaviors: - behavior_schema = {} - indices = merged_tagged_value_dict(behavior_utility.interface, index.key) - for field_name, index_info in indices.items(): - field_type = index_info.get("type", "text") - behavior_schema[field_name] = field_type - - if behavior_schema: - schema_info["behaviors"][behavior_name] = behavior_schema - - schema_info["field_types"] = self._categorize_field_types(schema_info) - schema_info["request_context"] = self.get_request_context() - - return schema_info - - def _categorize_field_types(self, schema_info: dict) -> dict: - """ - Categorize fields by type for easier query building. - """ - field_types = { - "numeric": [], - "date": [], - "text": [], - "keyword": [], - } - - all_fields = {} - for type_name, fields in schema_info["content_types"].items(): - for field_name, field_type in fields.items(): - key = f"{type_name}.{field_name}" - if key not in all_fields: - all_fields[key] = field_type - - for key, field_type in all_fields.items(): - if field_type in ("int", "float", "decimal"): - field_types["numeric"].append(key) - elif field_type in ("date", "datetime"): - field_types["date"].append(key) - elif field_type == "keyword": - field_types["keyword"].append(key) - else: - field_types["text"].append(key) - - return field_types diff --git a/guillotina/contrib/ai_query/services.py b/guillotina/contrib/ai_query/services.py deleted file mode 100644 index fd291d3e6..000000000 --- a/guillotina/contrib/ai_query/services.py +++ /dev/null @@ -1,357 +0,0 @@ -from guillotina import configure -from guillotina._settings import app_settings -from guillotina.api.service import Service -from guillotina.component import query_utility -from guillotina.contrib.ai_query.handler import AIQueryHandler -from guillotina.contrib.ai_query.llm_logger import LLMInteractionLogger -from guillotina.contrib.ai_query.result_processor import ResultProcessor -from guillotina.interfaces import ICatalogUtility -from guillotina.interfaces import IResource -from guillotina.response import HTTPPreconditionFailed -from guillotina.response import HTTPServiceUnavailable -from guillotina.response import Response -from typing import Dict -from typing import List -from typing import Optional -from uuid import uuid4 - -import json -import logging -import time - - -logger = logging.getLogger("guillotina") - - -@configure.service( - context=IResource, - method="POST", - permission="guillotina.ai_query.Query", - name="@ai-query", - summary="Query data using natural language", - requestBody={ - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Natural language query"}, - "response_format": { - "type": "string", - "enum": ["natural", "structured"], - "default": "natural", - }, - "conversation_id": { - "type": "string", - "description": "Optional conversation ID for context", - }, - "context": { - "type": "array", - "description": "Previous conversation messages", - "items": { - "type": "object", - "properties": { - "role": {"type": "string", "enum": ["user", "assistant"]}, - "content": {"type": "string"}, - }, - }, - }, - "stream": { - "type": "boolean", - "description": "Stream the answer as Server-Sent Events", - "default": False, - }, - }, - "required": ["query"], - } - } - }, - }, - responses={ - "200": { - "description": "Query results", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "answer": {"type": "string"}, - "data": {"type": "object"}, - "conversation_id": {"type": "string"}, - }, - } - } - }, - } - }, -) -class AIQueryService(Service): - async def __call__(self): - data = await self.get_data() - - query_text = data.get("query") - if not query_text: - raise HTTPPreconditionFailed(content={"reason": "query is required"}) - - response_format = data.get("response_format", "natural") - stream = data.get("stream", False) - conversation_id = data.get("conversation_id") or str(uuid4()) - context = data.get("context", []) - - settings = app_settings.get("ai_query", {}) - if not settings.get("enabled", True): - raise HTTPServiceUnavailable(content={"reason": "AI query is not enabled"}) - - request_id = str(uuid4()) - interaction_logger = None - if settings.get("log_llm_interactions") and settings.get("log_llm_dir"): - interaction_logger = LLMInteractionLogger( - request_id=request_id, - enabled=True, - log_dir=settings.get("log_llm_dir"), - ) - interaction_logger.log_request_start(query_text) - - ai_query_handler = AIQueryHandler() - - try: - schema_info = await ai_query_handler.get_schema_info(self.context) - - conversation_history = self._prepare_conversation_history(context, settings) - - translated_query = await ai_query_handler.translate_query( - query_text, - self.context, - schema_info, - conversation_history, - interaction_logger=interaction_logger, - ) - - if ai_query_handler._is_step_response(translated_query): - processed_results, _ = await self._run_multi_step( - ai_query_handler, - query_text, - schema_info, - conversation_history, - translated_query, - settings, - interaction_logger=interaction_logger, - ) - else: - t0 = time.perf_counter() - search_results = await self._execute_query(translated_query) - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_catalog_search( - translated_query, search_results, duration_seconds=duration - ) - if ( - search_results.get("items_total", 0) == 0 - and settings.get("retry_on_empty", True) - and not translated_query.get("aggregation") - ): - retry_response = await ai_query_handler.translate_retry_on_empty( - query_text, - self.context, - schema_info, - translated_query, - conversation_history, - interaction_logger=interaction_logger, - ) - if ai_query_handler._is_step_response(retry_response): - alt_query = dict(retry_response["query"]) - t0 = time.perf_counter() - search_results = await self._execute_query(alt_query) - duration = time.perf_counter() - t0 - if interaction_logger: - interaction_logger.log_catalog_search( - alt_query, - search_results, - duration_seconds=duration, - ) - translated_query = alt_query - aggregation_config = translated_query.get("aggregation") - if aggregation_config: - processed_results = ResultProcessor.process_results(search_results, aggregation_config) - else: - processed_results = search_results - - if response_format == "natural": - if stream: - return await self._stream_response( - ai_query_handler, - query_text, - processed_results, - schema_info, - conversation_history, - conversation_id, - interaction_logger=interaction_logger, - ) - answer = await ai_query_handler.generate_response( - query_text, - processed_results, - schema_info, - conversation_history, - interaction_logger=interaction_logger, - ) - return { - "answer": answer, - "data": processed_results, - "conversation_id": conversation_id, - } - else: - return processed_results - - except ValueError as e: - logger.error("Query validation error: %s", e, exc_info=True) - raise HTTPPreconditionFailed(content={"reason": str(e)}) - except Exception as e: - logger.error("AI query error: %s", e, exc_info=True) - raise HTTPServiceUnavailable(content={"reason": f"Query processing failed: {str(e)}"}) - finally: - if interaction_logger: - interaction_logger.log_request_end() - interaction_logger.close() - - async def _execute_query(self, query: dict) -> dict: - """Execute the translated query using catalog utility.""" - catalog = query_utility(ICatalogUtility) - if catalog is None: - raise HTTPServiceUnavailable(content={"reason": "Catalog utility not available"}) - - aggregation = query.pop("aggregation", None) - collect_all = query.pop("_collect_all", False) - - try: - if collect_all and aggregation: - page_size = int(app_settings.get("catalog_max_results", 50)) - all_items: List[Dict] = [] - offset = 0 - total_known = None - while True: - q = {**query, "_from": offset, "_size": page_size} - batch = await catalog.search(self.context, q) - items = batch.get("items", []) - all_items.extend(items) - total_known = batch.get("items_total") - if total_known is not None and len(all_items) >= total_known: - break - if len(items) < page_size: - break - offset += len(items) - if aggregation: - query["aggregation"] = aggregation - return {"items": all_items, "items_total": len(all_items)} - results = await catalog.search(self.context, query) - if aggregation: - query["aggregation"] = aggregation - return results - except Exception as e: - logger.error(f"Query execution error: {e}", exc_info=True) - raise ValueError(f"Failed to execute query: {str(e)}") - - async def _stream_response( - self, - ai_query_handler, - query_text: str, - processed_results: dict, - schema_info: dict, - conversation_history: Optional[List[Dict]], - conversation_id: str, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> Response: - """Return SSE stream: event chunk with data, then event done with payload.""" - resp = Response(status=200) - resp.content_type = "text/event-stream" - resp.headers["Cache-Control"] = "no-cache" - resp.headers["X-Accel-Buffering"] = "no" - await resp.prepare(self.request) - - try: - async for chunk in ai_query_handler.generate_response_stream( - query_text, - processed_results, - schema_info, - conversation_history, - interaction_logger=interaction_logger, - ): - sse_lines = "\n".join(f"data: {line}" for line in chunk.split("\n")) - await resp.write(f"{sse_lines}\n\n".encode("utf-8"), eof=False) - done = json.dumps( - { - "data": processed_results, - "conversation_id": conversation_id, - } - ).decode("utf-8") - await resp.write(f"event: done\ndata: {done}\n\n".encode("utf-8"), eof=False) - except Exception as e: - logger.error(f"Stream error: {e}", exc_info=True) - err = json.dumps({"error": "Stream error."}).decode("utf-8") - await resp.write(f"event: error\ndata: {err}\n\n".encode("utf-8"), eof=False) - await resp.write(b"", eof=True) - return resp - - async def _run_multi_step( - self, - ai_query_handler, - query_text: str, - schema_info: dict, - conversation_history: Optional[List[Dict]], - first_step_response: dict, - settings: dict, - interaction_logger: Optional[LLMInteractionLogger] = None, - ) -> tuple: - """ - Execute multi-step agent loop: run first query, then repeatedly - translate_next_step and execute until _action answer or max_steps. - Returns (processed_results_for_final_answer, step_results). - """ - max_steps = int(settings.get("max_steps", 5)) - step_results: List[Dict] = [] - current_query = dict(first_step_response["query"]) - last_processed: Optional[Dict] = None - - for step_index in range(1, max_steps + 1): - t0 = time.perf_counter() - search_results = await self._execute_query(current_query) - duration = time.perf_counter() - t0 - step_results.append({"query": dict(current_query), "result": search_results}) - if interaction_logger: - interaction_logger.log_catalog_search( - dict(current_query), - search_results, - step_index=step_index, - duration_seconds=duration, - ) - agg = current_query.get("aggregation") - last_processed = ResultProcessor.process_results(search_results, agg) if agg else search_results - - next_response = await ai_query_handler.translate_next_step( - query_text, - self.context, - schema_info, - step_results, - conversation_history, - interaction_logger=interaction_logger, - step_index=step_index, - ) - if next_response.get("_action") == "answer": - return (last_processed, step_results) - if ai_query_handler._is_step_response(next_response): - current_query = dict(next_response["query"]) - continue - break - - return (last_processed or {}, step_results) - - def _prepare_conversation_history(self, context: List[Dict], settings: dict) -> Optional[List[Dict]]: - """Prepare conversation history from context.""" - if not settings.get("enable_conversation", True): - return None - - if not context: - return None - - max_history = settings.get("max_conversation_history", 10) - return context[-max_history:] if len(context) > max_history else context diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index 273b7af42..0ffc1a324 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -1,3 +1,4 @@ +from contextvars import ContextVar from guillotina.component import get_multi_adapter from guillotina.component import query_utility from guillotina.interfaces import ICatalogUtility @@ -29,8 +30,6 @@ async def list_children( pass -from contextvars import ContextVar - _mcp_context_var: ContextVar[typing.Optional[IResource]] = ContextVar("mcp_context", default=None) diff --git a/guillotina/contrib/mcp/command.py b/guillotina/contrib/mcp/command.py index 464430c0a..fe25cfcf2 100644 --- a/guillotina/contrib/mcp/command.py +++ b/guillotina/contrib/mcp/command.py @@ -2,7 +2,6 @@ from guillotina.contrib.mcp.backend import HttpBackend from guillotina.contrib.mcp.server import get_mcp_server -import argparse import asyncio import logging import os diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index f7d85d6c6..0ade108ad 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -2,4 +2,7 @@ class IMCPDescriptionExtras(Interface): - """Callable utility returning a dict mapping tool name to extra description text (appended to the base tool description for LLM context). Tool names: search, count, get_content, list_children.""" + """Utility returning a dict mapping tool name to extra description text + (appended to the base tool description for LLM context). + Tool names: search, count, get_content, list_children. + """ diff --git a/guillotina/contrib/mcp/server.py b/guillotina/contrib/mcp/server.py index 72ed0a469..a79014463 100644 --- a/guillotina/contrib/mcp/server.py +++ b/guillotina/contrib/mcp/server.py @@ -2,6 +2,7 @@ from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.tools import register_tools + _mcp_server_instance = None diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index 9da5b7582..d754c6207 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -4,11 +4,28 @@ import typing + TOOL_DESCRIPTIONS = { - "search": "Search the catalog. container_path is optional (default: current context). query keys follow Guillotina @search: type_name, term, _size, _from, _sort_asc (field name for ascending), _sort_des (field name for descending), _metadata, _metadata_not; field filters: field__eq, field__not, field__gt, field__gte, field__lt, field__lte, field__in. E.g. creators__in to filter by creator (value: user id or list).", - "count": "Count catalog results. container_path is optional. query uses same keys as search: type_name, term, field__eq, field__gt, etc. (no _size/_from/_sort_asc/_sort_des).", - "get_content": "Get a resource by path (relative to container) or by UID. container_path is optional for in-process.", - "list_children": "List direct children of a container. path: relative path to container. from_index: offset (maps to _from). page_size: page size (maps to _size). container_path is optional for in-process.", + "search": ( + "Search the catalog. container_path is optional (default: current context). " + "query keys follow Guillotina @search: type_name, term, _size, _from, _sort_asc " + "(field name for ascending), _sort_des (field name for descending), _metadata, " + "_metadata_not; field filters: field__eq, field__not, field__gt, field__gte, " + "field__lt, field__lte, field__in. E.g. creators__in to filter by creator." + ), + "count": ( + "Count catalog results. container_path is optional. query uses same keys as search: " + "type_name, term, field__eq, field__gt, etc. (no _size/_from/_sort_asc/_sort_des)." + ), + "get_content": ( + "Get a resource by path (relative to container) or by UID. " + "container_path is optional for in-process." + ), + "list_children": ( + "List direct children of a container. path: relative path to container. " + "from_index: offset (maps to _from). page_size: page size (maps to _size). " + "container_path is optional for in-process." + ), } diff --git a/guillotina/tests/test_ai_query.py b/guillotina/tests/test_ai_query.py deleted file mode 100644 index 02a622713..000000000 --- a/guillotina/tests/test_ai_query.py +++ /dev/null @@ -1,86 +0,0 @@ -from guillotina.contrib.ai_query.result_processor import ResultProcessor -from guillotina.contrib.ai_query.schema_analyzer import SchemaAnalyzer -from guillotina.contrib.ai_query.handler import AIQueryHandler -from guillotina.tests import utils - -import json -import pytest - - -pytestmark = pytest.mark.asyncio - - -@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) -async def test_schema_analyzer_discovers_content_types(container_requester): - async with container_requester as requester: - resp, status = await requester("GET", "/db/guillotina") - assert status == 200 - - container = await utils.get_container(requester=requester) - analyzer = SchemaAnalyzer(container) - schema_info = await analyzer.get_schema_info() - - assert "content_types" in schema_info - assert "behaviors" in schema_info - assert "field_types" in schema_info - assert len(schema_info["content_types"]) > 0 - - -@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) -async def test_result_processor_sum_aggregation(): - items = [ - {"hours": 8.0, "developer": "Alice"}, - {"hours": 6.0, "developer": "Bob"}, - {"hours": 7.5, "developer": "Alice"}, - ] - - results = {"items": items, "items_total": 3} - aggregation = {"operation": "sum", "field": "hours"} - - processed = ResultProcessor.process_results(results, aggregation) - assert processed["total"] == 21.5 - assert processed["items_count"] == 3 - - -@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) -async def test_result_processor_count_aggregation(): - items = [ - {"type": "Document"}, - {"type": "Folder"}, - {"type": "Document"}, - ] - - results = {"items": items, "items_total": 3} - aggregation = {"operation": "count", "group_by": "type"} - - processed = ResultProcessor.process_results(results, aggregation) - assert "by_type" in processed - assert processed["by_type"]["Document"] == 2 - assert processed["by_type"]["Folder"] == 1 - - -@pytest.mark.app_settings({"applications": ["guillotina.contrib.ai_query"]}) -async def test_llm_query_handler_available(container_requester): - async with container_requester as requester: - handler = AIQueryHandler() - assert handler is not None - assert hasattr(handler, "translate_query") - assert hasattr(handler, "generate_response") - assert hasattr(handler, "generate_response_stream") - assert hasattr(handler, "get_schema_info") - - -@pytest.mark.app_settings( - { - "applications": ["guillotina.contrib.ai_query"], - "ai_query": {"enabled": False}, - } -) -async def test_ai_query_disabled(container_requester): - async with container_requester as requester: - resp, status = await requester( - "POST", - "/db/guillotina/@ai-query", - data=json.dumps({"query": "test query"}), - ) - assert status == 503 diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 9fb534556..a63f6153e 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -1,9 +1,9 @@ +from guillotina.contrib.mcp.backend import clear_mcp_context +from guillotina.contrib.mcp.backend import InProcessBackend + import json import pytest -from guillotina.contrib.mcp.backend import InProcessBackend -from guillotina.contrib.mcp.backend import clear_mcp_context - pytestmark = pytest.mark.asyncio From bd98a757930d1aaaf75609b200b670c2efcfc69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 14:10:13 +0100 Subject: [PATCH 07/36] feat: Add code formatting and testing targets to Makefile --- Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Makefile b/Makefile index 4211fd047..99fd35520 100644 --- a/Makefile +++ b/Makefile @@ -41,3 +41,12 @@ create-cockroachdb: ## Create CockroachDB @echo "" @echo "$(YELLOW)==> Creating CockroachDB $(VERSION)$(RESET)" ./bin/python _cockroachdb-createdb.py + +.PHONY: format +format: ## Format code + flake8 guillotina --config=setup.cfg + black guillotina/ + isort -rc guillotina/ + +tests: + DATABASE=POSTGRES pytest -s -x guillotina \ No newline at end of file From 1cfb2f6ec619cae64f1572745ecdce52a25792cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 14:49:11 +0100 Subject: [PATCH 08/36] feat: Refactor MCP integration by removing unused components and enhancing server setup --- guillotina/contrib/mcp/README.md | 229 +++++++++++++++++++++++++++++ guillotina/contrib/mcp/__init__.py | 9 -- guillotina/contrib/mcp/backend.py | 148 ++----------------- guillotina/contrib/mcp/command.py | 55 ------- guillotina/contrib/mcp/server.py | 27 ++-- guillotina/contrib/mcp/services.py | 15 +- guillotina/contrib/mcp/tools.py | 8 +- guillotina/tests/test_mcp.py | 36 ++++- 8 files changed, 293 insertions(+), 234 deletions(-) create mode 100644 guillotina/contrib/mcp/README.md delete mode 100644 guillotina/contrib/mcp/command.py diff --git a/guillotina/contrib/mcp/README.md b/guillotina/contrib/mcp/README.md new file mode 100644 index 000000000..805fc5e06 --- /dev/null +++ b/guillotina/contrib/mcp/README.md @@ -0,0 +1,229 @@ +# guillotina.contrib.mcp + +Contrib that exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for Guillotina as an HTTP service. It provides **read-only tools** (search, count, get_content, list_children). + +## Installation + +1. Add the package to your dependencies (e.g. `contrib-requirements.txt` or `requirements.txt`): + + ``` + mcp>=1.0.0 + ``` + +2. Add the contrib to your application in `config.yaml`: + + ```yaml + applications: + - guillotina + - guillotina.contrib.mcp + ``` + +## Configuration + +Default settings are defined in the contrib; you can override them in your app config: + +```yaml +mcp: + enabled: true +``` + +| Setting | Description | +|--------|-------------| +| `mcp.enabled` | If `false`, the `@mcp` service returns 404. Default: `true`. | +| `mcp.description_extras` | Optional dict: tool name → string appended to that tool's description (for LLM context). Keys: `search`, `count`, `get_content`, `list_children`. | +| `mcp.extra_tools_module` | Optional dotted path to a module that defines `register_extra_tools(mcp_server, backend)` to add project-specific MCP tools. | +| `mcp.token_max_duration_days` | Maximum allowed `duration_days` for `@mcp-token`. Default: `90`. | +| `mcp.token_allowed_durations` | Optional list of allowed values for `duration_days` (e.g. `[30, 60, 90]`). If not set, any integer from 1 to `token_max_duration_days` is allowed. | + +## MCP service + +The MCP server is exposed as a **service** on any resource. The resource (e.g. a container) is the context for all tools. + +- **Endpoint**: `POST` or `GET` on `/{db}/{container_path}/@mcp` (e.g. `POST /db/guillotina/@mcp`). +- **Authentication**: Use normal Guillotina auth on the request to `@mcp`. The same user permissions apply. Two options: + - **Bearer JWT**: Obtain a token via `POST /{db}/{container}/@login` (short-lived, e.g. 1 hour) or via `POST /{db}/{container}/@mcp-token` (long-lived, 30/60/90 days or custom). Send `Authorization: Bearer ` on each request to `@mcp`. For stable IDE/client configuration, prefer `@mcp-token`. + - **Basic auth**: Send the same username/password as for Guillotina login (e.g. `Authorization: Basic `). +- **Use case**: Same process as Guillotina. + +Example with Basic auth: + +```bash +curl -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +Example with Bearer token (from `@mcp-token`): + +```bash +# First obtain a long-lived token (e.g. 30 days) +TOKEN=$(curl -s -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp-token" \ + -H "Content-Type: application/json" \ + -d '{"duration_days": 30}' | jq -r .token) +# Then call @mcp with the token +curl -X POST -H "Authorization: Bearer $TOKEN" "http://localhost:8080/db/guillotina/@mcp" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +**Important:** The MCP transport requires the client to send `Accept: application/json, text/event-stream`. In Postman, add a header `Accept` = `application/json, text/event-stream`; otherwise you get HTTP 406 Not Acceptable. + +### Endpoint `@mcp-token` (long-lived token for MCP) + +Authenticated users can request a **long-lived JWT** for configuring MCP clients (e.g. Cursor, IDEs) without renewing every hour. + +- **Endpoint**: `POST /{db}/{container_path}/@mcp-token` +- **Permission**: User must be authenticated (same as `@mcp`). +- **Body** (optional): `{"duration_days": 30}`. Default is 30. Allowed range: 1 to `mcp.token_max_duration_days` (default 90), or only values in `mcp.token_allowed_durations` if that option is set. +- **Response**: `{"token": "", "exp": , "duration_days": }`. Use the `token` as `Authorization: Bearer ` when calling `@mcp`. + +## Tools (read-only) + +| Tool | Description | +|------|-------------| +| `search` | Catalog search (like `@search`). Parameters: container path, query (filters, `_size`, `_from`, `_sort_asc` / `_sort_des`). | +| `count` | Count catalog results (like `@count`). | +| `get_content` | Get a resource by path or UID (like `GET` by path or `@resolveuid`). | +| `list_children` | List direct children of a container (like `@items` with pagination). | + +## Com sap l’LLM quins paràmetres usar? + +El client (Cursor, VS Code, etc.) **no** té els paràmetres hardcodats. Els obté del servidor MCP en connectar-se, mitjançant el protocol MCP. + +1. **Descoberta d’eines** + En connectar (o en refrescar), el client envia una petició MCP estàndard **`tools/list`** al servidor Guillotina MCP. El servidor respon amb la llista d’eines disponibles i, per a cadascuna: + - **nom** (p. ex. `search`, `get_content`) + - **descripció** (el docstring de la funció) + - **esquema d’entrada** (nom i tipus de cada paràmetre, extrets de la signatura de la funció) + +2. **D’on surt l’esquema** + Al contrib, les eines es defineixen a `tools.py` amb `@mcp_server.tool()` sobre funcions async amb tipat. El SDK MCP (FastMCP) llegeix la signatura i els docstrings i genera l’esquema que s’envia al client. Per exemple: + + ```python + @mcp_server.tool() + async def search( + container_path: typing.Optional[str] = None, + query: typing.Optional[typing.Dict[str, str]] = None, + ) -> dict: + """Search the catalog. container_path is optional...""" + ``` + + Es converteix en una definició MCP amb `search`, la descripció i els paràmetres `container_path` (string, opcional) i `query` (object, opcional). El client rep aquest JSON i el passa a l’LLM. + +3. **Com ho usa l’LLM** + Cursor (o un altre client) inclou aquestes definicions d’eines en el context de l’assistent. Quan l’usuari fa una pregunta (“quina carpeta té més items?”, “busca contenidors de tipus X”), l’model tria quina eina cridar i amb quins arguments (p. ex. `search` amb `query: {"type_name": "Folder"}`). El client envia **`tools/call`** amb el nom de l’eina i els arguments; el servidor MCP executa la funció (el nostre backend) i retorna el resultat, que el client torna a passar a l’LLM per continuar la conversa. + +En resum: **l’LLM sap els paràmetres perquè el servidor MCP els publica via `tools/list`**, i aquests venen directament de les funcions que definim a `tools.py` (signatura + docstring). No cal configurar res més al client; només la URL i l’auth. + +Per eines com `search` i `count`, el paràmetre `query` és un diccionari lliure (no hi ha esquema MCP per les claus). **L’LLM només “sap” quines claus pot usar (p. ex. `_sort_asc`, `_sort_des`, `type_name`) perquè les documentem al docstring de l’eina.** Al contrib hem posat al docstring els noms que segueix l’API @search de Guillotina; si afegiu més paràmetres a l’API de cerca, convé actualitzar el docstring de `search` (i `count` si aplica) perquè l’LLM els tingui al context. + +## Permissions + +The `@mcp` service requires the permission **`guillotina.mcp.Use`**, which is granted to **`guillotina.Authenticated`** by the contrib. Adjust grants in your app if you need to restrict or extend access. + +## Extending the MCP for your project + +Projects can give the LLM more context (e.g. your content types, field meanings) and add custom tools. + +### Enriching tool descriptions + +Extra text is appended to the base description of each tool so the LLM sees project-specific context. + +**Option 1: config** — In your app config, set `mcp.description_extras` to a dict mapping tool name to a string: + +```yaml +mcp: + description_extras: + search: "In this project, type_name 'Document' is the main content type; 'Folder' for containers." + get_content: "Resources may have custom fields; use path relative to the container." +``` + +**Option 2: utility** — Register a callable utility providing `guillotina.contrib.mcp.interfaces.IMCPDescriptionExtras`. When called, it must return a dict `tool_name -> extra description string`. Use this when the text depends on code (e.g. content types from the registry). + +```python +from guillotina import configure +from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras + +@configure.utility(provides=IMCPDescriptionExtras) +class MyDescriptionExtras: + def __call__(self): + return { + "search": "In this project, type_name 'Document' is the main content type.", + } +``` + +Config and utility are merged; utility values are appended after config for the same tool. + +### Adding custom tools + +Set `mcp.extra_tools_module` to a dotted path of a module that defines a function **`register_extra_tools(mcp_server, backend)`**. That function receives the FastMCP server and InProcessBackend and can register more tools with `@mcp_server.tool()`. + +```yaml +mcp: + extra_tools_module: "myapp.mcp_tools" +``` + +```python +# myapp/mcp_tools.py +def register_extra_tools(mcp_server, backend): + @mcp_server.tool() + async def my_custom_tool(container_path: str = None, query: str = "") -> dict: + """My project-specific tool. Does X with container_path and query.""" + # Use backend.search(..., backend.get_content(...), etc. as needed + ... +``` + +--- + +## Configuració en clients (Cursor / VS Code) + +Els clients MCP (Cursor, VS Code amb extensió MCP) es connecten al servidor Guillotina MCP mitjançant **Streamable HTTP**. La connexió es fa a l'endpoint `@mcp` del mateix servei Guillotina. + +### Cursor + +1. Obre **Settings → Developer → Edit Config** (o **Features → MCP → Add New MCP Server**). +2. Edita `~/.cursor/mcp.json` (global) o `.cursor/mcp.json` del projecte. +3. Afegeix un servidor amb `url` i, capçalera d’autenticació Basic. + +```json +{ + "mcpServers": { + "guillotina": { + "url": "http://localhost:8080/db/guillotina/@mcp", + "headers": { + "Authorization": "Basic ${env:GUILLOTINA_BASIC_AUTH}" + } + } + } +} +``` + +Cal que `GUILLOTINA_BASIC_AUTH` sigui el Base64 de `usuari:password` (per exemple `cm9vdDpwYXNzd29yZA==` per `root:password`). En terminal: + +```bash +echo -n "root:password" | base64 +``` + +Si preferiu no usar variable d’entorn, podeu posar la capçalera directament (eviteu en repos compartits): + +```json +"headers": { + "Authorization": "Basic cm9vdDpwYXNzd29yZA==" +} +``` + +Després de desar `mcp.json`, Cursor carrega les eines (search, count, get_content, list_children). Si no les veieu, feu un refresh o reinicia Cursor. + +### VS Code + +Amb la **extensió Model Context Protocol** (i VS Code 1.102+): + +1. **Extensions** (Ctrl+Shift+X) → cercar “Model Context Protocol” → instal·lar. +2. Configuració MCP: depèn de l’extensió; si usa un fitxer tipus `mcp.json`, la forma és equivalent a Cursor: entrada amb `url` i opcionalment `headers` per Basic auth. + +- URL: `http://localhost:8080/db//@mcp` +- Headers: `Authorization: Basic ` + +Consulteu la documentació de l’extensió MCP per a VS Code per a la ubicació exacta del fitxer de configuració i el format (sovint similar al de Cursor). diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index fd6111822..c02f2983b 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -4,20 +4,11 @@ app_settings = { "mcp": { "enabled": True, - "base_url": None, - "auth": { - "type": "basic", - "username": "root", - "password": None, - }, "description_extras": {}, "extra_tools_module": None, "token_max_duration_days": 90, "token_allowed_durations": None, }, - "commands": { - "mcp-server": "guillotina.contrib.mcp.command.MCPServerCommand", - }, } diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index 0ffc1a324..27fad3859 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -6,30 +6,10 @@ from guillotina.interfaces import IResourceSerializeToJson from guillotina.utils import get_object_by_uid from guillotina.utils import navigate_to -from zope.interface import Interface import typing -class IMCPBackend(Interface): - async def search(context: IResource, query: dict) -> dict: - pass - - async def count(context: IResource, query: dict) -> int: - pass - - async def get_content(context: IResource, path: typing.Optional[str], uid: typing.Optional[str]) -> dict: - pass - - async def list_children( - context: IResource, - path: str, - _from: int = 0, - _size: int = 20, - ) -> dict: - pass - - _mcp_context_var: ContextVar[typing.Optional[IResource]] = ContextVar("mcp_context", default=None) @@ -55,15 +35,24 @@ def _get_base_context(self) -> IResource: raise RuntimeError("MCP context not set (not in @mcp request?)") return ctx + def _resolve_context(self, context: typing.Optional[IResource]) -> IResource: + if context is None: + return self._get_base_context() + if not IResource.providedBy(context): + raise RuntimeError( + "InProcessBackend requires IResource context. Use the @mcp endpoint or set MCP context." + ) + return context + async def search(self, context: IResource, query: dict) -> dict: - base = context if context is not None else self._get_base_context() + base = self._resolve_context(context) search = query_utility(ICatalogUtility) if search is None: return {"items": [], "items_total": 0} return await search.search(base, query) async def count(self, context: IResource, query: dict) -> int: - base = context if context is not None else self._get_base_context() + base = self._resolve_context(context) search = query_utility(ICatalogUtility) if search is None: return 0 @@ -77,7 +66,7 @@ async def get_content( ) -> dict: from guillotina import task_vars - base = context if context is not None else self._get_base_context() + base = self._resolve_context(context) request = task_vars.request.get() if uid: try: @@ -111,7 +100,7 @@ async def list_children( _from: int = 0, _size: int = 20, ) -> dict: - base = context if context is not None else self._get_base_context() + base = self._resolve_context(context) path = path.strip("/") or "" try: container = await navigate_to(base, "/" + path) if path else base @@ -135,114 +124,3 @@ async def list_children( items.append(await summary_serializer()) total += 1 return {"items": items, "items_total": total} - - -def _encode_basic_auth(username: str, password: str) -> str: - import base64 - - credentials = f"{username}:{password or ''}" - return base64.b64encode(credentials.encode()).decode() - - -class HttpBackend: - def __init__(self, base_url: str, username: str, password: str): - self.base_url = base_url.rstrip("/") - self.username = username - self.password = password or "" - self._auth_header = f"Basic {_encode_basic_auth(username, password)}" - - def _url(self, path: str, query: typing.Optional[dict] = None) -> str: - path = path.strip("/") - url = f"{self.base_url}/{path}" - if query: - from urllib.parse import urlencode - - url += "?" + urlencode(query) - return url - - async def search(self, context: IResource, query: dict) -> dict: - import httpx - - path = context if isinstance(context, str) else None - if not path: - return {"items": [], "items_total": 0} - url = self._url(f"{path}/@search", query) - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) - resp.raise_for_status() - return resp.json() - - async def count(self, context: IResource, query: dict) -> int: - import httpx - - path = context if isinstance(context, str) else None - if not path: - return 0 - url = self._url(f"{path}/@count", query) - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) - resp.raise_for_status() - return resp.json() - - async def get_content( - self, - context: IResource, - path: typing.Optional[str], - uid: typing.Optional[str], - ) -> dict: - import httpx - - base_path = context if isinstance(context, str) else None - if not base_path: - return {} - if uid: - url = self._url(f"{base_path}/@resolveuid/{uid}") - async with httpx.AsyncClient(follow_redirects=True) as client: - resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) - if resp.status_code == 404: - return {} - resp.raise_for_status() - target = resp.headers.get("location") or "" - if not target.startswith("http"): - return {} - resp2 = await client.get(target, headers={"Authorization": self._auth_header}, timeout=30.0) - resp2.raise_for_status() - return resp2.json() - elif path is not None: - rel = path.strip("/") - url_path = f"{base_path}/{rel}" if rel else base_path - url = self._url(url_path) - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) - if resp.status_code == 404: - return {} - resp.raise_for_status() - return resp.json() - return {} - - async def list_children( - self, - context: IResource, - path: str, - _from: int = 0, - _size: int = 20, - ) -> dict: - import httpx - - base_path = context if isinstance(context, str) else None - if not base_path: - return {"items": [], "items_total": 0} - rel = path.strip("/") if path else "" - url_path = f"{base_path}/{rel}" if rel else base_path - page = (_from // _size) + 1 if _size else 1 - query = {"page": str(page), "page_size": str(_size or 20)} - url = self._url(f"{url_path}/@items", query) - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers={"Authorization": self._auth_header}, timeout=30.0) - if resp.status_code == 404: - return {"items": [], "items_total": 0} - resp.raise_for_status() - data = resp.json() - items = data.get("items", []) if isinstance(data, dict) else [] - total = data.get("total", len(items)) if isinstance(data, dict) else len(items) - return {"items": items, "items_total": total} diff --git a/guillotina/contrib/mcp/command.py b/guillotina/contrib/mcp/command.py deleted file mode 100644 index fe25cfcf2..000000000 --- a/guillotina/contrib/mcp/command.py +++ /dev/null @@ -1,55 +0,0 @@ -from guillotina.commands import Command -from guillotina.contrib.mcp.backend import HttpBackend -from guillotina.contrib.mcp.server import get_mcp_server - -import asyncio -import logging -import os - - -logger = logging.getLogger("guillotina") - - -class MCPServerCommand(Command): - description = "Run MCP server (out-of-process) that connects to Guillotina via REST." - - def get_parser(self): - parser = super(MCPServerCommand, self).get_parser() - parser.add_argument("--base-url", help="Guillotina base URL (e.g. http://localhost:8080)") - parser.add_argument("--username", help="Basic auth username") - parser.add_argument("--password", help="Basic auth password") - parser.add_argument("--host", default="0.0.0.0", help="MCP server host") - parser.add_argument("--port", type=int, default=8000, help="MCP server port") - return parser - - async def run(self, arguments, settings, app): - mcp_settings = settings.get("mcp", {}) - auth = mcp_settings.get("auth", {}) - base_url = ( - getattr(arguments, "base_url", None) - or mcp_settings.get("base_url") - or os.environ.get("MCP_GUILLOTINA_BASE_URL") - ) - username = ( - getattr(arguments, "username", None) - or auth.get("username") - or os.environ.get("MCP_GUILLOTINA_USERNAME", "root") - ) - password = ( - getattr(arguments, "password", None) - or auth.get("password") - or os.environ.get("MCP_GUILLOTINA_PASSWORD", "") - ) - if not base_url: - logger.error("base_url is required (config mcp.base_url, --base-url, or MCP_GUILLOTINA_BASE_URL)") - return - backend = HttpBackend(base_url=base_url, username=username, password=password) - server = get_mcp_server(backend) - host = getattr(arguments, "host", "0.0.0.0") - port = getattr(arguments, "port", 8000) - - def run_server(): - server.run(transport="streamable-http", host=host, port=port) - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, run_server) diff --git a/guillotina/contrib/mcp/server.py b/guillotina/contrib/mcp/server.py index a79014463..901639885 100644 --- a/guillotina/contrib/mcp/server.py +++ b/guillotina/contrib/mcp/server.py @@ -3,14 +3,17 @@ from guillotina.contrib.mcp.tools import register_tools -_mcp_server_instance = None +_mcp_server = None +_mcp_app = None -def get_mcp_server(backend=None): +def get_mcp_app_and_server(): + global _mcp_server, _mcp_app + if _mcp_app is not None: + return _mcp_app, _mcp_server from mcp.server.fastmcp import FastMCP - if backend is None: - backend = InProcessBackend() + backend = InProcessBackend() mcp = FastMCP( "Guillotina MCP", json_response=True, @@ -23,16 +26,6 @@ def get_mcp_server(backend=None): if extra_module: mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) getattr(mod, "register_extra_tools")(mcp, backend) - return mcp - - -def get_mcp_server_instance(): - return _mcp_server_instance - - -def get_mcp_asgi_app(backend=None): - global _mcp_server_instance - server = get_mcp_server(backend) - app = server.streamable_http_app() - _mcp_server_instance = server - return app + _mcp_server = mcp + _mcp_app = mcp.streamable_http_app() + return _mcp_app, _mcp_server diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 921c1876b..eaa1e8710 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -5,8 +5,7 @@ from guillotina.auth.users import AnonymousUser from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import set_mcp_context -from guillotina.contrib.mcp.server import get_mcp_asgi_app -from guillotina.contrib.mcp.server import get_mcp_server_instance +from guillotina.contrib.mcp.server import get_mcp_app_and_server from guillotina.interfaces import IResource from guillotina.response import HTTPPreconditionFailed from guillotina.response import HTTPUnauthorized @@ -20,15 +19,6 @@ logger = logging.getLogger("guillotina") -_mcp_asgi_app = None - - -def _get_mcp_app(): - global _mcp_asgi_app - if _mcp_asgi_app is None: - _mcp_asgi_app = get_mcp_asgi_app() - return _mcp_asgi_app - @configure.service( context=IResource, @@ -54,8 +44,7 @@ async def mcp_service(context, request): scope = copy.copy(request.scope) scope["path"] = "/" scope["raw_path"] = b"/" - app = _get_mcp_app() - server = get_mcp_server_instance() + app, server = get_mcp_app_and_server() session_manager = server.session_manager original_task_group = session_manager._task_group async with anyio.create_task_group() as tg: diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index d754c6207..65fe09631 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -43,16 +43,16 @@ def _get_description_extras(): def register_tools(mcp_server, backend): def _context_for_path(container_path: typing.Optional[str]): ctx = get_mcp_context() - if ctx is not None and container_path: + if ctx is None: + return None + if container_path: from guillotina.utils import navigate_to try: return navigate_to(ctx, "/" + container_path.strip("/")) except KeyError: return None - if ctx is not None: - return ctx - return container_path + return ctx extras = _get_description_extras() diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index a63f6153e..244781f08 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -13,7 +13,33 @@ async def test_mcp_service_registered(container_requester): pytest.importorskip("mcp") async with container_requester as requester: resp, status = await requester("GET", "/db/guillotina/@mcp") - assert status in (200, 401, 404) + assert status in (200, 401, 404, 421) + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": False}}) +async def test_mcp_disabled_returns_404(container_requester): + pytest.importorskip("mcp") + async with container_requester as requester: + _, status = await requester("GET", "/db/guillotina/@mcp") + assert status == 404 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_mcp_tools_list(container_requester): + pytest.importorskip("mcp") + async with container_requester as requester: + resp, status = await requester( + "POST", + "/db/guillotina/@mcp", + data=json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1}), + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + ) + assert status in (200, 401, 421) + if status == 200 and isinstance(resp, dict): + assert "result" in resp or "error" in resp @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) @@ -24,6 +50,14 @@ async def test_inprocess_backend_search_requires_context(container_requester): await backend.search(None, {}) +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_inprocess_backend_rejects_string_context(container_requester): + backend = InProcessBackend() + clear_mcp_context() + with pytest.raises(RuntimeError, match="InProcessBackend requires IResource context"): + await backend.search("db/guillotina", {}) + + @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_mcp_token_requires_auth(container_requester): async with container_requester as requester: From abd32a3c2645a8a697f489abd2b70bd85f966008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 14:49:49 +0100 Subject: [PATCH 09/36] feat: Remove MCP README documentation as part of project cleanup --- guillotina/contrib/mcp/README.md | 229 ------------------------------- 1 file changed, 229 deletions(-) delete mode 100644 guillotina/contrib/mcp/README.md diff --git a/guillotina/contrib/mcp/README.md b/guillotina/contrib/mcp/README.md deleted file mode 100644 index 805fc5e06..000000000 --- a/guillotina/contrib/mcp/README.md +++ /dev/null @@ -1,229 +0,0 @@ -# guillotina.contrib.mcp - -Contrib that exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for Guillotina as an HTTP service. It provides **read-only tools** (search, count, get_content, list_children). - -## Installation - -1. Add the package to your dependencies (e.g. `contrib-requirements.txt` or `requirements.txt`): - - ``` - mcp>=1.0.0 - ``` - -2. Add the contrib to your application in `config.yaml`: - - ```yaml - applications: - - guillotina - - guillotina.contrib.mcp - ``` - -## Configuration - -Default settings are defined in the contrib; you can override them in your app config: - -```yaml -mcp: - enabled: true -``` - -| Setting | Description | -|--------|-------------| -| `mcp.enabled` | If `false`, the `@mcp` service returns 404. Default: `true`. | -| `mcp.description_extras` | Optional dict: tool name → string appended to that tool's description (for LLM context). Keys: `search`, `count`, `get_content`, `list_children`. | -| `mcp.extra_tools_module` | Optional dotted path to a module that defines `register_extra_tools(mcp_server, backend)` to add project-specific MCP tools. | -| `mcp.token_max_duration_days` | Maximum allowed `duration_days` for `@mcp-token`. Default: `90`. | -| `mcp.token_allowed_durations` | Optional list of allowed values for `duration_days` (e.g. `[30, 60, 90]`). If not set, any integer from 1 to `token_max_duration_days` is allowed. | - -## MCP service - -The MCP server is exposed as a **service** on any resource. The resource (e.g. a container) is the context for all tools. - -- **Endpoint**: `POST` or `GET` on `/{db}/{container_path}/@mcp` (e.g. `POST /db/guillotina/@mcp`). -- **Authentication**: Use normal Guillotina auth on the request to `@mcp`. The same user permissions apply. Two options: - - **Bearer JWT**: Obtain a token via `POST /{db}/{container}/@login` (short-lived, e.g. 1 hour) or via `POST /{db}/{container}/@mcp-token` (long-lived, 30/60/90 days or custom). Send `Authorization: Bearer ` on each request to `@mcp`. For stable IDE/client configuration, prefer `@mcp-token`. - - **Basic auth**: Send the same username/password as for Guillotina login (e.g. `Authorization: Basic `). -- **Use case**: Same process as Guillotina. - -Example with Basic auth: - -```bash -curl -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' -``` - -Example with Bearer token (from `@mcp-token`): - -```bash -# First obtain a long-lived token (e.g. 30 days) -TOKEN=$(curl -s -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp-token" \ - -H "Content-Type: application/json" \ - -d '{"duration_days": 30}' | jq -r .token) -# Then call @mcp with the token -curl -X POST -H "Authorization: Bearer $TOKEN" "http://localhost:8080/db/guillotina/@mcp" \ - -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' -``` - -**Important:** The MCP transport requires the client to send `Accept: application/json, text/event-stream`. In Postman, add a header `Accept` = `application/json, text/event-stream`; otherwise you get HTTP 406 Not Acceptable. - -### Endpoint `@mcp-token` (long-lived token for MCP) - -Authenticated users can request a **long-lived JWT** for configuring MCP clients (e.g. Cursor, IDEs) without renewing every hour. - -- **Endpoint**: `POST /{db}/{container_path}/@mcp-token` -- **Permission**: User must be authenticated (same as `@mcp`). -- **Body** (optional): `{"duration_days": 30}`. Default is 30. Allowed range: 1 to `mcp.token_max_duration_days` (default 90), or only values in `mcp.token_allowed_durations` if that option is set. -- **Response**: `{"token": "", "exp": , "duration_days": }`. Use the `token` as `Authorization: Bearer ` when calling `@mcp`. - -## Tools (read-only) - -| Tool | Description | -|------|-------------| -| `search` | Catalog search (like `@search`). Parameters: container path, query (filters, `_size`, `_from`, `_sort_asc` / `_sort_des`). | -| `count` | Count catalog results (like `@count`). | -| `get_content` | Get a resource by path or UID (like `GET` by path or `@resolveuid`). | -| `list_children` | List direct children of a container (like `@items` with pagination). | - -## Com sap l’LLM quins paràmetres usar? - -El client (Cursor, VS Code, etc.) **no** té els paràmetres hardcodats. Els obté del servidor MCP en connectar-se, mitjançant el protocol MCP. - -1. **Descoberta d’eines** - En connectar (o en refrescar), el client envia una petició MCP estàndard **`tools/list`** al servidor Guillotina MCP. El servidor respon amb la llista d’eines disponibles i, per a cadascuna: - - **nom** (p. ex. `search`, `get_content`) - - **descripció** (el docstring de la funció) - - **esquema d’entrada** (nom i tipus de cada paràmetre, extrets de la signatura de la funció) - -2. **D’on surt l’esquema** - Al contrib, les eines es defineixen a `tools.py` amb `@mcp_server.tool()` sobre funcions async amb tipat. El SDK MCP (FastMCP) llegeix la signatura i els docstrings i genera l’esquema que s’envia al client. Per exemple: - - ```python - @mcp_server.tool() - async def search( - container_path: typing.Optional[str] = None, - query: typing.Optional[typing.Dict[str, str]] = None, - ) -> dict: - """Search the catalog. container_path is optional...""" - ``` - - Es converteix en una definició MCP amb `search`, la descripció i els paràmetres `container_path` (string, opcional) i `query` (object, opcional). El client rep aquest JSON i el passa a l’LLM. - -3. **Com ho usa l’LLM** - Cursor (o un altre client) inclou aquestes definicions d’eines en el context de l’assistent. Quan l’usuari fa una pregunta (“quina carpeta té més items?”, “busca contenidors de tipus X”), l’model tria quina eina cridar i amb quins arguments (p. ex. `search` amb `query: {"type_name": "Folder"}`). El client envia **`tools/call`** amb el nom de l’eina i els arguments; el servidor MCP executa la funció (el nostre backend) i retorna el resultat, que el client torna a passar a l’LLM per continuar la conversa. - -En resum: **l’LLM sap els paràmetres perquè el servidor MCP els publica via `tools/list`**, i aquests venen directament de les funcions que definim a `tools.py` (signatura + docstring). No cal configurar res més al client; només la URL i l’auth. - -Per eines com `search` i `count`, el paràmetre `query` és un diccionari lliure (no hi ha esquema MCP per les claus). **L’LLM només “sap” quines claus pot usar (p. ex. `_sort_asc`, `_sort_des`, `type_name`) perquè les documentem al docstring de l’eina.** Al contrib hem posat al docstring els noms que segueix l’API @search de Guillotina; si afegiu més paràmetres a l’API de cerca, convé actualitzar el docstring de `search` (i `count` si aplica) perquè l’LLM els tingui al context. - -## Permissions - -The `@mcp` service requires the permission **`guillotina.mcp.Use`**, which is granted to **`guillotina.Authenticated`** by the contrib. Adjust grants in your app if you need to restrict or extend access. - -## Extending the MCP for your project - -Projects can give the LLM more context (e.g. your content types, field meanings) and add custom tools. - -### Enriching tool descriptions - -Extra text is appended to the base description of each tool so the LLM sees project-specific context. - -**Option 1: config** — In your app config, set `mcp.description_extras` to a dict mapping tool name to a string: - -```yaml -mcp: - description_extras: - search: "In this project, type_name 'Document' is the main content type; 'Folder' for containers." - get_content: "Resources may have custom fields; use path relative to the container." -``` - -**Option 2: utility** — Register a callable utility providing `guillotina.contrib.mcp.interfaces.IMCPDescriptionExtras`. When called, it must return a dict `tool_name -> extra description string`. Use this when the text depends on code (e.g. content types from the registry). - -```python -from guillotina import configure -from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras - -@configure.utility(provides=IMCPDescriptionExtras) -class MyDescriptionExtras: - def __call__(self): - return { - "search": "In this project, type_name 'Document' is the main content type.", - } -``` - -Config and utility are merged; utility values are appended after config for the same tool. - -### Adding custom tools - -Set `mcp.extra_tools_module` to a dotted path of a module that defines a function **`register_extra_tools(mcp_server, backend)`**. That function receives the FastMCP server and InProcessBackend and can register more tools with `@mcp_server.tool()`. - -```yaml -mcp: - extra_tools_module: "myapp.mcp_tools" -``` - -```python -# myapp/mcp_tools.py -def register_extra_tools(mcp_server, backend): - @mcp_server.tool() - async def my_custom_tool(container_path: str = None, query: str = "") -> dict: - """My project-specific tool. Does X with container_path and query.""" - # Use backend.search(..., backend.get_content(...), etc. as needed - ... -``` - ---- - -## Configuració en clients (Cursor / VS Code) - -Els clients MCP (Cursor, VS Code amb extensió MCP) es connecten al servidor Guillotina MCP mitjançant **Streamable HTTP**. La connexió es fa a l'endpoint `@mcp` del mateix servei Guillotina. - -### Cursor - -1. Obre **Settings → Developer → Edit Config** (o **Features → MCP → Add New MCP Server**). -2. Edita `~/.cursor/mcp.json` (global) o `.cursor/mcp.json` del projecte. -3. Afegeix un servidor amb `url` i, capçalera d’autenticació Basic. - -```json -{ - "mcpServers": { - "guillotina": { - "url": "http://localhost:8080/db/guillotina/@mcp", - "headers": { - "Authorization": "Basic ${env:GUILLOTINA_BASIC_AUTH}" - } - } - } -} -``` - -Cal que `GUILLOTINA_BASIC_AUTH` sigui el Base64 de `usuari:password` (per exemple `cm9vdDpwYXNzd29yZA==` per `root:password`). En terminal: - -```bash -echo -n "root:password" | base64 -``` - -Si preferiu no usar variable d’entorn, podeu posar la capçalera directament (eviteu en repos compartits): - -```json -"headers": { - "Authorization": "Basic cm9vdDpwYXNzd29yZA==" -} -``` - -Després de desar `mcp.json`, Cursor carrega les eines (search, count, get_content, list_children). Si no les veieu, feu un refresh o reinicia Cursor. - -### VS Code - -Amb la **extensió Model Context Protocol** (i VS Code 1.102+): - -1. **Extensions** (Ctrl+Shift+X) → cercar “Model Context Protocol” → instal·lar. -2. Configuració MCP: depèn de l’extensió; si usa un fitxer tipus `mcp.json`, la forma és equivalent a Cursor: entrada amb `url` i opcionalment `headers` per Basic auth. - -- URL: `http://localhost:8080/db//@mcp` -- Headers: `Authorization: Basic ` - -Consulteu la documentació de l’extensió MCP per a VS Code per a la ubicació exacta del fitxer de configuració i el format (sovint similar al de Cursor). From b18ee392a5f2ba369ff89a3484ac78ead2152af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 17:47:56 +0100 Subject: [PATCH 10/36] feat: Add chat functionality to MCP with LLM integration --- guillotina/contrib/mcp/__init__.py | 3 + guillotina/contrib/mcp/chat.py | 159 +++++++++++++++++++++++++++ guillotina/contrib/mcp/chat_tools.py | 97 ++++++++++++++++ guillotina/tests/test_mcp.py | 73 ++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 guillotina/contrib/mcp/chat.py create mode 100644 guillotina/contrib/mcp/chat_tools.py diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index c02f2983b..8f8bd8efb 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -8,6 +8,8 @@ "extra_tools_module": None, "token_max_duration_days": 90, "token_allowed_durations": None, + "chat_enabled": True, + "chat_model": None, }, } @@ -15,3 +17,4 @@ def includeme(root, settings): configure.scan("guillotina.contrib.mcp.permissions") configure.scan("guillotina.contrib.mcp.services") + configure.scan("guillotina.contrib.mcp.chat") diff --git a/guillotina/contrib/mcp/chat.py b/guillotina/contrib/mcp/chat.py new file mode 100644 index 000000000..f23664b4f --- /dev/null +++ b/guillotina/contrib/mcp/chat.py @@ -0,0 +1,159 @@ +from guillotina import configure +from guillotina._settings import app_settings +from guillotina.api.service import Service +from guillotina.contrib.mcp.backend import clear_mcp_context +from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.contrib.mcp.backend import set_mcp_context +from guillotina.contrib.mcp.chat_tools import get_chat_tools +from guillotina.interfaces import IResource +from guillotina.response import HTTPNotFound +from guillotina.response import HTTPPreconditionFailed + +import json +import logging +import os + + +logger = logging.getLogger("guillotina") + +MAX_TOOL_ROUNDS = 10 + + +def _get_api_key_for_model(model: str) -> str: + if model.startswith("openai/"): + return os.environ.get("OPENAI_API_KEY") or "" + if model.startswith("gemini/"): + return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or "" + if model.startswith("anthropic/"): + return os.environ.get("ANTHROPIC_API_KEY") or "" + return "" + + +async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): + args = arguments or {} + if name == "search": + return await backend.search(None, args.get("query") or {}) + if name == "count": + return await backend.count(None, args.get("query") or {}) + if name == "get_content": + return await backend.get_content( + None, + args.get("path"), + args.get("uid"), + ) + if name == "list_children": + return await backend.list_children( + None, + args.get("path") or "", + args.get("from_index", 0), + args.get("page_size", 20), + ) + return {"error": f"Unknown tool: {name}"} + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.mcp.Use", + name="@chat", + summary="Chat with LLM using MCP tools (OpenAI, Gemini, Anthropic)", +) +class Chat(Service): + __body_required__ = False + + async def __call__(self): + mcp_settings = app_settings.get("mcp", {}) + if not mcp_settings.get("chat_enabled", True): + raise HTTPNotFound(content={"reason": "Chat is disabled"}) + chat_model = mcp_settings.get("chat_model") + if not chat_model: + raise HTTPPreconditionFailed( + content={ + "reason": "chat_model is not configured", + "hint": "Set mcp.chat_model (e.g. openai/gpt-4o)", + } + ) + try: + body = await self.request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + message = body.get("message") + messages = body.get("messages") + if messages is not None: + if not isinstance(messages, list): + raise HTTPPreconditionFailed(content={"reason": "messages must be a list"}) + elif message is not None: + messages = [{"role": "user", "content": str(message)}] + else: + raise HTTPPreconditionFailed(content={"reason": "message or messages is required"}) + + set_mcp_context(self.context) + try: + return await self._run_chat(messages, chat_model, mcp_settings) + finally: + clear_mcp_context() + + async def _run_chat(self, messages: list, chat_model: str, mcp_settings: dict): + litellm = __import__("litellm", fromlist=["acompletion"]) + acompletion = getattr(litellm, "acompletion") + tools = get_chat_tools() + api_key = _get_api_key_for_model(chat_model) + backend = InProcessBackend() + kwargs = {"model": chat_model, "messages": messages, "tools": tools} + if api_key: + kwargs["api_key"] = api_key + for _ in range(MAX_TOOL_ROUNDS): + response = await acompletion(**kwargs) + choice = response.choices[0] if response.choices else None + if not choice: + raise HTTPPreconditionFailed(content={"reason": "Empty response from LLM"}) + msg = choice.message + tool_calls = getattr(msg, "tool_calls", None) or [] + if not tool_calls: + content = getattr(msg, "content", None) or "" + return {"content": content} + assistant_msg = {"role": "assistant", "content": getattr(msg, "content", None)} + tool_calls_list = [] + for tc in tool_calls: + tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else "") + fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else {}) + name = getattr(fn, "name", None) if fn else None + if name is None and isinstance(fn, dict): + name = fn.get("name", "") + raw_args = getattr(fn, "arguments", None) if fn else None + if raw_args is None and isinstance(fn, dict): + raw_args = fn.get("arguments", "{}") + tool_calls_list.append( + { + "id": tc_id, + "type": "function", + "function": {"name": name or "", "arguments": raw_args or "{}"}, + } + ) + assistant_msg["tool_calls"] = tool_calls_list + messages.append(assistant_msg) + for tc in tool_calls: + tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else "") + fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else {}) + name = getattr(fn, "name", None) if fn else None + if name is None and isinstance(fn, dict): + name = fn.get("name", "") + raw_args = getattr(fn, "arguments", None) if fn else None + if raw_args is None and isinstance(fn, dict): + raw_args = fn.get("arguments", "{}") + name = name or "" + raw_args = raw_args or "{}" + try: + arguments = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {}) + except json.JSONDecodeError: + arguments = {} + try: + result = await _execute_tool(backend, name, arguments) + except Exception as e: + logger.exception("MCP chat tool %s failed", name) + result = {"error": str(e)} + messages.append({"role": "tool", "tool_call_id": tc_id, "content": json.dumps(result)}) + kwargs["messages"] = messages + raise HTTPPreconditionFailed(content={"reason": f"Max tool rounds ({MAX_TOOL_ROUNDS}) exceeded"}) diff --git a/guillotina/contrib/mcp/chat_tools.py b/guillotina/contrib/mcp/chat_tools.py new file mode 100644 index 000000000..ba80d25fd --- /dev/null +++ b/guillotina/contrib/mcp/chat_tools.py @@ -0,0 +1,97 @@ +from guillotina.contrib.mcp.tools import _get_description_extras +from guillotina.contrib.mcp.tools import TOOL_DESCRIPTIONS + + +def get_chat_tools(): + """Return MCP tool definitions in the format expected by LiteLLM/chat completion APIs (all providers).""" + extras = _get_description_extras() + descriptions = { + name: (TOOL_DESCRIPTIONS[name] + " " + (extras.get(name) or "")).strip() for name in TOOL_DESCRIPTIONS + } + return [ + { + "type": "function", + "function": { + "name": "search", + "description": descriptions["search"], + "parameters": { + "type": "object", + "properties": { + "container_path": { + "type": "string", + "description": "Optional path relative to container.", + }, + "query": { + "type": "object", + "description": "Search query: type_name, term, _size, _from, _sort_asc, _sort_des, field filters.", # noqa: E501 + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "count", + "description": descriptions["count"], + "parameters": { + "type": "object", + "properties": { + "container_path": { + "type": "string", + "description": "Optional path relative to container.", + }, + "query": { + "type": "object", + "description": "Count query: type_name, term, field filters (no _size/_from/_sort).", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_content", + "description": descriptions["get_content"], + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to container."}, + "uid": {"type": "string", "description": "Resource UID."}, + "container_path": { + "type": "string", + "description": "Optional path relative to container.", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_children", + "description": descriptions["list_children"], + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to container."}, + "from_index": { + "type": "integer", + "description": "Offset (maps to _from).", + "default": 0, + }, + "page_size": { + "type": "integer", + "description": "Page size (maps to _size).", + "default": 20, + }, + "container_path": { + "type": "string", + "description": "Optional path relative to container.", + }, + }, + }, + }, + }, + ] diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 244781f08..7d4ea64bb 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -1,8 +1,11 @@ from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend +from unittest.mock import AsyncMock +from unittest.mock import MagicMock import json import pytest +import sys pytestmark = pytest.mark.asyncio @@ -80,3 +83,73 @@ async def test_mcp_token_returns_long_lived_token(container_requester): ) assert status_custom == 200 assert resp_custom.get("duration_days") == 60 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"chat_enabled": False}}) +async def test_chat_disabled_returns_404(container_requester): + pytest.importorskip("litellm") + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/@chat", + data=json.dumps({"message": "hello"}), + ) + assert status == 404 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_chat_no_model_returns_412(container_requester): + pytest.importorskip("litellm") + async with container_requester as requester: + resp, status = await requester( + "POST", + "/db/guillotina/@chat", + data=json.dumps({"message": "hello"}), + ) + assert status == 412 + assert "chat_model" in str(resp.get("reason", "")) + + +@pytest.mark.app_settings( + { + "applications": ["guillotina.contrib.mcp"], + "mcp": {"chat_enabled": True, "chat_model": "openai/gpt-4o-mini"}, + } +) +async def test_chat_no_message_returns_412(container_requester): + pytest.importorskip("litellm") + async with container_requester as requester: + _, status = await requester("POST", "/db/guillotina/@chat", data=json.dumps({})) + assert status == 412 + + +@pytest.mark.app_settings( + { + "applications": ["guillotina.contrib.mcp"], + "mcp": {"chat_enabled": True, "chat_model": "openai/gpt-4o-mini"}, + } +) +async def test_chat_returns_content_with_mock(container_requester): + pytest.importorskip("litellm") + mock_message = type("Message", (), {"content": "Hello back", "tool_calls": None})() + mock_choice = type("Choice", (), {"message": mock_message})() + mock_response = type("Response", (), {"choices": [mock_choice]})() + + mock_litellm = MagicMock() + mock_litellm.acompletion = AsyncMock(return_value=mock_response) + orig_litellm = sys.modules.get("litellm") + sys.modules["litellm"] = mock_litellm + try: + async with container_requester as requester: + resp, status = await requester( + "POST", + "/db/guillotina/@chat", + data=json.dumps({"message": "hello"}), + ) + assert status == 200 + assert resp.get("content") == "Hello back" + finally: + if orig_litellm is not None: + sys.modules["litellm"] = orig_litellm + elif "litellm" in sys.modules: + del sys.modules["litellm"] From 3d4c7c4954c0232be98cba8533392144b1532c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 17:59:11 +0100 Subject: [PATCH 11/36] feat: Introduce MCP tools and chat endpoint with enhanced configuration - Added new MCP documentation to describe the Model Context Protocol integration. - Implemented the `@mcp` and `@chat` endpoints for LLM interactions. - Refactored tool definitions to support additional chat functionalities. - Removed deprecated chat tools and updated related service logic for better performance. --- docs/source/contrib/index.rst | 1 + docs/source/contrib/mcp.md | 179 +++++++++++++++++++++++++++ guillotina/contrib/mcp/chat.py | 8 +- guillotina/contrib/mcp/chat_tools.py | 97 --------------- guillotina/contrib/mcp/tools.py | 74 +++++++++++ 5 files changed, 260 insertions(+), 99 deletions(-) create mode 100644 docs/source/contrib/mcp.md delete mode 100644 guillotina/contrib/mcp/chat_tools.py diff --git a/docs/source/contrib/index.rst b/docs/source/contrib/index.rst index 2af6a4ea0..dc4fb8030 100644 --- a/docs/source/contrib/index.rst +++ b/docs/source/contrib/index.rst @@ -16,4 +16,5 @@ Contents: pubsub swagger mailer + mcp dbusers diff --git a/docs/source/contrib/mcp.md b/docs/source/contrib/mcp.md new file mode 100644 index 000000000..74a52c16f --- /dev/null +++ b/docs/source/contrib/mcp.md @@ -0,0 +1,179 @@ +# MCP (Model Context Protocol) + +The `guillotina.contrib.mcp` package exposes Guillotina content to [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) clients and provides a chat endpoint where an LLM can query content using the same tools. + +**What you get:** + +- **@mcp** — MCP-over-HTTP endpoint so IDEs (Cursor, VS Code, etc.) and other MCP clients can discover and call tools against a container. +- **@chat** — REST endpoint to send a message and get an LLM reply; the LLM can call the same tools (search, count, get_content, list_children) in-process. API keys stay on the server. + +Both use the same read-only tools and the same permissions. + +## Installation + +1. Add dependencies (e.g. in `contrib-requirements.txt` or `requirements.txt`): + + ``` + mcp>=1.0.0 + litellm>=1.0.0 + ``` + + `litellm` is required only if you use **@chat**. + +2. Enable the contrib in your app config: + + ```yaml + applications: + - guillotina + - guillotina.contrib.mcp + ``` + +## Configuration + +You can override these in your application config: + +| Setting | Description | +|--------|-------------| +| `mcp.enabled` | If `false`, `@mcp` returns 404. Default: `true`. | +| `mcp.chat_enabled` | If `false`, `@chat` returns 404. Default: `true`. | +| `mcp.chat_model` | Model for @chat (LiteLLM). Required if you use chat. Examples: `openai/gpt-4o-mini`, `gemini/gemini-1.5-flash`, `anthropic/claude-3-haiku`. | +| `mcp.token_max_duration_days` | Max `duration_days` for `@mcp-token`. Default: `90`. | +| `mcp.token_allowed_durations` | Optional list of allowed values (e.g. `[30, 60, 90]`). If set, only these values are accepted. | +| `mcp.description_extras` | Optional dict: tool name → string appended to that tool’s description (for LLM context). Keys: `search`, `count`, `get_content`, `list_children`. | +| `mcp.extra_tools_module` | Optional dotted path to a module that defines `register_extra_tools(mcp_server, backend)` (and optionally chat extensions). See [Extending](#extending). | + +For **@chat**, the LLM API key is read **only from environment variables**: `OPENAI_API_KEY`, `GEMINI_API_KEY` (or `GOOGLE_API_KEY`), or `ANTHROPIC_API_KEY` depending on `mcp.chat_model`. Do not put API keys in config files. + +## Using the MCP endpoint (@mcp) + +- **URL**: `POST` or `GET` on `/{db}/{container_path}/@mcp` (e.g. `POST /db/guillotina/@mcp`). +- **Auth**: Same as any Guillotina request. Use either: + - **Basic**: `Authorization: Basic `. + - **Bearer**: Obtain a token with `POST /{db}/{container}/@login` or `POST /{db}/{container}/@mcp-token`, then `Authorization: Bearer `. +- **Headers**: Clients must send `Accept: application/json, text/event-stream`; otherwise the server returns 406. + +The resource you call (e.g. a container) is the context for all tools: search, count, get_content, and list_children are scoped to that resource. + +**Obtaining a long-lived token for MCP clients** + +- **Endpoint**: `POST /{db}/{container_path}/@mcp-token` +- **Auth**: Required (e.g. Basic or short-lived JWT). +- **Body** (optional): `{"duration_days": 30}`. Default 30; max and allowed values come from config. +- **Response**: `{"token": "", "exp": , "duration_days": }`. + +Use this token as `Authorization: Bearer ` when calling `@mcp` from Cursor, VS Code, or other MCP clients. + +**Example (list tools)** + +```bash +# With Basic auth +curl -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +MCP clients discover tools via the standard MCP protocol (e.g. `tools/list`, `tools/call`). Configure your IDE/client with the `@mcp` URL and the same auth (Basic or Bearer). + +## Using the chat endpoint (@chat) + +- **URL**: `POST /{db}/{container_path}/@chat` +- **Auth**: Same as `@mcp` (e.g. Bearer from `@mcp-token` or `@login`). +- **Body**: + - Single message: `{"message": "user text"}`. + - Full history (to keep context): `{"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...]}`. +- **Response**: `{"content": "assistant reply text"}`. + +The server runs an LLM (LiteLLM) and executes the same tools (search, count, get_content, list_children) when the model requests them. To keep conversation context, your client should accumulate all messages and send them in `messages` on each request. + +**Example** + +```bash +TOKEN=$(curl -s -X POST -u root:password "http://localhost:8080/db/guillotina/@mcp-token" \ + -H "Content-Type: application/json" -d '{"duration_days": 30}' | jq -r .token) + +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + "http://localhost:8080/db/guillotina/@chat" \ + -H "Content-Type: application/json" \ + -d '{"message": "How many items are in this container?"}' | jq . +``` + +## Built-in tools + +All tools are read-only and scoped to the resource (container) you call @mcp or @chat on: + +| Tool | Purpose | +|------|---------| +| `search` | Catalog search (same query keys as Guillotina `@search`: `type_name`, `term`, `_size`, `_from`, `_sort_asc` / `_sort_des`, field filters like `field__eq`, etc.). | +| `count` | Count catalog results with the same query filters (no `_size` / `_from` / sort). | +| `get_content` | Get a resource by path (relative to container) or by UID. | +| `list_children` | List direct children of a path, with optional pagination (`from_index`, `page_size`). | + +Parameters and descriptions are exposed via MCP (`tools/list`) and to the LLM in @chat automatically. + +## Permissions + +- **@mcp** and **@chat** require permission `guillotina.mcp.Use`. +- **@mcp-token** requires `guillotina.mcp.IssueToken`. +- The contrib grants both to `guillotina.Authenticated`. Adjust grants in your app if you need to restrict or extend access. + +## Extending + +### Richer tool descriptions + +Set `mcp.description_extras` to a dict mapping tool name to extra text (appended to the built-in description), or register a utility providing `guillotina.contrib.mcp.interfaces.IMCPDescriptionExtras` that returns such a dict. Useful to describe your content types or project-specific usage. + +### Custom tools (MCP and optional @chat) + +Set `mcp.extra_tools_module` to a dotted path to a module that defines: + +- **`register_extra_tools(mcp_server, backend)`** — Required. Register additional tools with `@mcp_server.tool()`. They are then available on **@mcp** (e.g. to Cursor/VS Code). Use `backend` (InProcessBackend) to call search, get_content, etc. from your tool. + +To make the same tools available in **@chat** (so the LLM can call them), the same module can optionally define: + +- **`get_extra_chat_tools()`** — Returns a list of tool definitions in the same format as the built-in ones: each item is `{"type": "function", "function": {"name": "...", "description": "...", "parameters": {"type": "object", "properties": {...}}}}`. +- **`execute_extra_tool(backend, name, arguments)`** — Async. Called when the LLM invokes one of your extra tools. Receives `backend`, tool `name`, and a dict of `arguments`; return a JSON-serialisable result. + +Tool names must be unique and must not clash with the built-in names: `search`, `count`, `get_content`, `list_children`. + +**Example** + +```yaml +# config +mcp: + extra_tools_module: "myapp.mcp_tools" +``` + +```python +# myapp/mcp_tools.py +def register_extra_tools(mcp_server, backend): + @mcp_server.tool() + async def my_tool(container_path: str = None, query: str = "") -> dict: + """My project tool. Does X with container_path and query.""" + # use backend.search(...), backend.get_content(...), etc. + return {"result": "..."} + +def get_extra_chat_tools(): + return [ + { + "type": "function", + "function": { + "name": "my_tool", + "description": "My project tool. Does X with container_path and query.", + "parameters": { + "type": "object", + "properties": { + "container_path": {"type": "string", "description": "Optional path relative to container."}, + "query": {"type": "string", "description": "Query."}, + }, + }, + }, + }, + ] + +async def execute_extra_tool(backend, name, arguments): + if name == "my_tool": + # run same logic as the MCP tool + return {"result": "..."} + return {"error": f"Unknown tool: {name}"} +``` diff --git a/guillotina/contrib/mcp/chat.py b/guillotina/contrib/mcp/chat.py index f23664b4f..fdc339c12 100644 --- a/guillotina/contrib/mcp/chat.py +++ b/guillotina/contrib/mcp/chat.py @@ -4,7 +4,8 @@ from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.backend import set_mcp_context -from guillotina.contrib.mcp.chat_tools import get_chat_tools +from guillotina.contrib.mcp.tools import get_all_chat_tools +from guillotina.contrib.mcp.tools import get_extra_tools_module from guillotina.interfaces import IResource from guillotina.response import HTTPNotFound from guillotina.response import HTTPPreconditionFailed @@ -48,6 +49,9 @@ async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): args.get("from_index", 0), args.get("page_size", 20), ) + mod = get_extra_tools_module() + if mod is not None and hasattr(mod, "execute_extra_tool"): + return await mod.execute_extra_tool(backend, name, args) return {"error": f"Unknown tool: {name}"} @@ -98,7 +102,7 @@ async def __call__(self): async def _run_chat(self, messages: list, chat_model: str, mcp_settings: dict): litellm = __import__("litellm", fromlist=["acompletion"]) acompletion = getattr(litellm, "acompletion") - tools = get_chat_tools() + tools = get_all_chat_tools() api_key = _get_api_key_for_model(chat_model) backend = InProcessBackend() kwargs = {"model": chat_model, "messages": messages, "tools": tools} diff --git a/guillotina/contrib/mcp/chat_tools.py b/guillotina/contrib/mcp/chat_tools.py deleted file mode 100644 index ba80d25fd..000000000 --- a/guillotina/contrib/mcp/chat_tools.py +++ /dev/null @@ -1,97 +0,0 @@ -from guillotina.contrib.mcp.tools import _get_description_extras -from guillotina.contrib.mcp.tools import TOOL_DESCRIPTIONS - - -def get_chat_tools(): - """Return MCP tool definitions in the format expected by LiteLLM/chat completion APIs (all providers).""" - extras = _get_description_extras() - descriptions = { - name: (TOOL_DESCRIPTIONS[name] + " " + (extras.get(name) or "")).strip() for name in TOOL_DESCRIPTIONS - } - return [ - { - "type": "function", - "function": { - "name": "search", - "description": descriptions["search"], - "parameters": { - "type": "object", - "properties": { - "container_path": { - "type": "string", - "description": "Optional path relative to container.", - }, - "query": { - "type": "object", - "description": "Search query: type_name, term, _size, _from, _sort_asc, _sort_des, field filters.", # noqa: E501 - }, - }, - }, - }, - }, - { - "type": "function", - "function": { - "name": "count", - "description": descriptions["count"], - "parameters": { - "type": "object", - "properties": { - "container_path": { - "type": "string", - "description": "Optional path relative to container.", - }, - "query": { - "type": "object", - "description": "Count query: type_name, term, field filters (no _size/_from/_sort).", - }, - }, - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_content", - "description": descriptions["get_content"], - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string", "description": "Path relative to container."}, - "uid": {"type": "string", "description": "Resource UID."}, - "container_path": { - "type": "string", - "description": "Optional path relative to container.", - }, - }, - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_children", - "description": descriptions["list_children"], - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string", "description": "Path relative to container."}, - "from_index": { - "type": "integer", - "description": "Offset (maps to _from).", - "default": 0, - }, - "page_size": { - "type": "integer", - "description": "Page size (maps to _size).", - "default": 20, - }, - "container_path": { - "type": "string", - "description": "Optional path relative to container.", - }, - }, - }, - }, - }, - ] diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index 65fe09631..53f64f5ad 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -28,6 +28,42 @@ ), } +CHAT_PARAM_SCHEMAS = { + "search": { + "properties": { + "container_path": {"type": "string", "description": "Optional path relative to container."}, + "query": { + "type": "object", + "description": "Search query: type_name, term, _size, _from, _sort_asc, _sort_des, field filters.", + }, + }, + }, + "count": { + "properties": { + "container_path": {"type": "string", "description": "Optional path relative to container."}, + "query": { + "type": "object", + "description": "Count query: type_name, term, field filters (no _size/_from/_sort).", + }, + }, + }, + "get_content": { + "properties": { + "path": {"type": "string", "description": "Path relative to container."}, + "uid": {"type": "string", "description": "Resource UID."}, + "container_path": {"type": "string", "description": "Optional path relative to container."}, + }, + }, + "list_children": { + "properties": { + "path": {"type": "string", "description": "Path relative to container."}, + "from_index": {"type": "integer", "description": "Offset (maps to _from).", "default": 0}, + "page_size": {"type": "integer", "description": "Page size (maps to _size).", "default": 20}, + "container_path": {"type": "string", "description": "Optional path relative to container."}, + }, + }, +} + def _get_description_extras(): from guillotina.component import query_utility @@ -104,3 +140,41 @@ async def list_children( if context is None and container_path is None: return {"items": [], "items_total": 0} return await backend.list_children(context, path or "", from_index, page_size) + + +def get_chat_tools(): + """Return built-in tool definitions in the format expected by LiteLLM for @chat (all providers).""" + extras = _get_description_extras() + descriptions = { + name: (TOOL_DESCRIPTIONS[name] + " " + (extras.get(name) or "")).strip() for name in TOOL_DESCRIPTIONS + } + return [ + { + "type": "function", + "function": { + "name": name, + "description": descriptions[name], + "parameters": {"type": "object", **CHAT_PARAM_SCHEMAS[name]}, + }, + } + for name in TOOL_DESCRIPTIONS + ] + + +def get_extra_tools_module(): + """Return the extra_tools_module if configured, else None.""" + path = app_settings.get("mcp", {}).get("extra_tools_module") + if not path: + return None + return __import__(str(path), fromlist=["register_extra_tools"]) + + +def get_all_chat_tools(): + """Return built-in + extra chat tools (same format). Projects can define get_extra_chat_tools() in extra_tools_module.""" # noqa: E501 + result = list(get_chat_tools()) + mod = get_extra_tools_module() + if mod is not None and hasattr(mod, "get_extra_chat_tools"): + extra = getattr(mod, "get_extra_chat_tools")() + if isinstance(extra, list): + result.extend(extra) + return result From 8ba8cf24797635621a887efa8214db2ffd0f5d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 18:00:08 +0100 Subject: [PATCH 12/36] feat: Add MCP contrib and update requirements --- CHANGELOG.rst | 1 + contrib-requirements.txt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c6d9d1ced..4c7492ba7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,7 @@ CHANGELOG - Docs: Update documentation and configuration settings - Chore: Update sphinx-guillotina-theme version to 1.0.9 +- Feat: Add MCP (Model Context Protocol) contrib [rboixaderg] diff --git a/contrib-requirements.txt b/contrib-requirements.txt index b7260ebc9..ff8b7170c 100644 --- a/contrib-requirements.txt +++ b/contrib-requirements.txt @@ -17,3 +17,5 @@ pymemcache==3.4.0; python_version < '3.10' # Conditional Pillow versions pillow==10.4.0; python_version < '3.11' pillow==11.1.0; python_version >= '3.11' +litellm>=1.0.0 +mcp>=1.0.0 From ae4938cef74fab013fc8a304effa0890def2a80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 18:09:52 +0100 Subject: [PATCH 13/36] chore: copilot suggestions --- guillotina/contrib/mcp/backend.py | 8 ++++---- guillotina/contrib/mcp/services.py | 8 +++++--- guillotina/tests/test_mcp.py | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index 27fad3859..d69e95d1c 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -25,7 +25,7 @@ def clear_mcp_context(): try: _mcp_context_var.set(None) except LookupError: - pass + pass # No active context set; clear operation is intentionally idempotent. class InProcessBackend: @@ -113,14 +113,14 @@ async def list_children( if not IFolder.providedBy(container): return {"items": [], "items_total": 0} request = task_vars.request.get() + items_total = await container.async_len() items = [] total = 0 async for name, child in container.async_items(): if total >= _from + _size: - total += 1 - continue + break if total >= _from: summary_serializer = get_multi_adapter((child, request), IResourceSerializeToJsonSummary) items.append(await summary_serializer()) total += 1 - return {"items": items, "items_total": total} + return {"items": items, "items_total": items_total} diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index eaa1e8710..e75dbf2a3 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -14,6 +14,7 @@ import anyio import copy +import json import logging @@ -41,12 +42,12 @@ async def mcp_service(context, request): raise HTTPNotFound(content={"reason": "MCP is disabled"}) set_mcp_context(context) try: - scope = copy.copy(request.scope) + scope = copy.copy(request.scope) # Only top-level keys modified; shallow copy sufficient. scope["path"] = "/" scope["raw_path"] = b"/" app, server = get_mcp_app_and_server() session_manager = server.session_manager - original_task_group = session_manager._task_group + original_task_group = session_manager._task_group # Workaround: mcp lib does not expose task group. async with anyio.create_task_group() as tg: session_manager._task_group = tg try: @@ -55,6 +56,7 @@ async def mcp_service(context, request): session_manager._task_group = original_task_group finally: clear_mcp_context() + # Response already sent via request.send(); return dummy so framework does not send again. resp = Response() resp._prepared = True resp._eof_sent = True @@ -79,7 +81,7 @@ async def __call__(self): raise HTTPUnauthorized(content={"reason": "Authentication required"}) try: body = await self.request.json() - except Exception: + except (json.JSONDecodeError, ValueError): body = {} if not isinstance(body, dict): body = {} diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 7d4ea64bb..ffd3eddfd 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -47,6 +47,7 @@ async def test_mcp_tools_list(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_inprocess_backend_search_requires_context(container_requester): + pytest.importorskip("mcp") backend = InProcessBackend() clear_mcp_context() with pytest.raises(RuntimeError, match="MCP context not set"): @@ -55,6 +56,7 @@ async def test_inprocess_backend_search_requires_context(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_inprocess_backend_rejects_string_context(container_requester): + pytest.importorskip("mcp") backend = InProcessBackend() clear_mcp_context() with pytest.raises(RuntimeError, match="InProcessBackend requires IResource context"): From f8701c0cd5b00f4de70736318bbc65c12e166e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 18:16:34 +0100 Subject: [PATCH 14/36] fix: Update MCP requirements and documentation for Python 3.10+ compatibility --- contrib-requirements.txt | 2 +- docs/source/contrib/mcp.md | 4 +++- guillotina/tests/test_mcp.py | 15 +++++++++------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/contrib-requirements.txt b/contrib-requirements.txt index ff8b7170c..e8cbb0ac9 100644 --- a/contrib-requirements.txt +++ b/contrib-requirements.txt @@ -18,4 +18,4 @@ pymemcache==3.4.0; python_version < '3.10' pillow==10.4.0; python_version < '3.11' pillow==11.1.0; python_version >= '3.11' litellm>=1.0.0 -mcp>=1.0.0 +mcp>=1.0.0; python_version >= "3.10" diff --git a/docs/source/contrib/mcp.md b/docs/source/contrib/mcp.md index 74a52c16f..ae8499a5d 100644 --- a/docs/source/contrib/mcp.md +++ b/docs/source/contrib/mcp.md @@ -11,10 +11,12 @@ Both use the same read-only tools and the same permissions. ## Installation +**Requires Python 3.10+** (the `mcp` package does not support older versions). + 1. Add dependencies (e.g. in `contrib-requirements.txt` or `requirements.txt`): ``` - mcp>=1.0.0 + mcp>=1.0.0; python_version >= "3.10" litellm>=1.0.0 ``` diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index ffd3eddfd..0b0d96cd4 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -8,12 +8,19 @@ import sys -pytestmark = pytest.mark.asyncio +try: + import mcp +except ImportError: + mcp = None + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(mcp is None, reason="mcp package requires Python 3.10+"), +] @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_mcp_service_registered(container_requester): - pytest.importorskip("mcp") async with container_requester as requester: resp, status = await requester("GET", "/db/guillotina/@mcp") assert status in (200, 401, 404, 421) @@ -21,7 +28,6 @@ async def test_mcp_service_registered(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": False}}) async def test_mcp_disabled_returns_404(container_requester): - pytest.importorskip("mcp") async with container_requester as requester: _, status = await requester("GET", "/db/guillotina/@mcp") assert status == 404 @@ -29,7 +35,6 @@ async def test_mcp_disabled_returns_404(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_mcp_tools_list(container_requester): - pytest.importorskip("mcp") async with container_requester as requester: resp, status = await requester( "POST", @@ -47,7 +52,6 @@ async def test_mcp_tools_list(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_inprocess_backend_search_requires_context(container_requester): - pytest.importorskip("mcp") backend = InProcessBackend() clear_mcp_context() with pytest.raises(RuntimeError, match="MCP context not set"): @@ -56,7 +60,6 @@ async def test_inprocess_backend_search_requires_context(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_inprocess_backend_rejects_string_context(container_requester): - pytest.importorskip("mcp") backend = InProcessBackend() clear_mcp_context() with pytest.raises(RuntimeError, match="InProcessBackend requires IResource context"): From 85f4f4415420cbcf2f0c972278d166a992eff572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 18:16:47 +0100 Subject: [PATCH 15/36] chore: Update Makefile to add .PHONY declaration for tests target --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 99fd35520..be7f06cc1 100644 --- a/Makefile +++ b/Makefile @@ -48,5 +48,6 @@ format: ## Format code black guillotina/ isort -rc guillotina/ -tests: +.PHONY: tests +tests: ## Run tests DATABASE=POSTGRES pytest -s -x guillotina \ No newline at end of file From 83abee07f55d7b63a2eb0df2efecfb062eccfa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 30 Jan 2026 18:19:23 +0100 Subject: [PATCH 16/36] chore: Update jinja2 and MarkupSafe versions in contrib-requirements.txt for compatibility --- contrib-requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib-requirements.txt b/contrib-requirements.txt index e8cbb0ac9..6c34c3a97 100644 --- a/contrib-requirements.txt +++ b/contrib-requirements.txt @@ -8,8 +8,8 @@ codecov==2.1.13 mypy-zope==1.0.11 black==22.3.0 isort==4.3.21 -jinja2==2.11.3 -MarkupSafe<2.1.0 +jinja2>=3.1.2,<4.0.0 +MarkupSafe>=2.0 pytz==2020.1 emcache==0.6.0; python_version < '3.10' pymemcache==3.4.0; python_version < '3.10' From a98eb19ba7c4c60ee6b01b1a06b9f483dcafabd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sat, 31 Jan 2026 08:33:38 +0100 Subject: [PATCH 17/36] feat: Enhance MCP chat model support and update documentation --- docs/source/contrib/mcp.md | 20 +++++++++++++++++-- guillotina/contrib/mcp/chat.py | 36 +++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/source/contrib/mcp.md b/docs/source/contrib/mcp.md index ae8499a5d..0a8721f6a 100644 --- a/docs/source/contrib/mcp.md +++ b/docs/source/contrib/mcp.md @@ -38,13 +38,29 @@ You can override these in your application config: |--------|-------------| | `mcp.enabled` | If `false`, `@mcp` returns 404. Default: `true`. | | `mcp.chat_enabled` | If `false`, `@chat` returns 404. Default: `true`. | -| `mcp.chat_model` | Model for @chat (LiteLLM). Required if you use chat. Examples: `openai/gpt-4o-mini`, `gemini/gemini-1.5-flash`, `anthropic/claude-3-haiku`. | +| `mcp.chat_model` | Model for @chat (LiteLLM). Required if you use chat. Examples: `openai/gpt-4o-mini`, `gemini/gemini-1.5-flash`, `anthropic/claude-3-haiku`, `groq/llama-3.1-8b-instant`, `openrouter/google/gemini-2.0-flash-001`, `minimax/MiniMax-M2.1`, `mistral/mistral-small-latest`, `deepseek/deepseek-chat`, `cerebras/llama3-70b-instruct`. | | `mcp.token_max_duration_days` | Max `duration_days` for `@mcp-token`. Default: `90`. | | `mcp.token_allowed_durations` | Optional list of allowed values (e.g. `[30, 60, 90]`). If set, only these values are accepted. | | `mcp.description_extras` | Optional dict: tool name → string appended to that tool’s description (for LLM context). Keys: `search`, `count`, `get_content`, `list_children`. | | `mcp.extra_tools_module` | Optional dotted path to a module that defines `register_extra_tools(mcp_server, backend)` (and optionally chat extensions). See [Extending](#extending). | -For **@chat**, the LLM API key is read **only from environment variables**: `OPENAI_API_KEY`, `GEMINI_API_KEY` (or `GOOGLE_API_KEY`), or `ANTHROPIC_API_KEY` depending on `mcp.chat_model`. Do not put API keys in config files. +For `mcp.chat_model`, use the `provider/model-name` format. For current model names use the [LiteLLM Providers](https://docs.litellm.ai/docs/providers) index and the docs for each provider. + +**@chat** reads credentials **only from environment variables**. Set the variables for the provider implied by your `mcp.chat_model`: + +| Provider | Required | Optional (base URL) | +|----------|----------|----------------------| +| OpenAI | `OPENAI_API_KEY` | — | +| Google (Gemini) | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | — | +| Anthropic | `ANTHROPIC_API_KEY` | — | +| Groq | `GROQ_API_KEY` | — | +| OpenRouter | `OPENROUTER_API_KEY` | `OPENROUTER_API_BASE` (default: `https://openrouter.ai/api/v1`) | +| MiniMax | `MINIMAX_API_KEY` | `MINIMAX_API_BASE` (e.g. `https://api.minimax.io/v1` for chat completions) | +| Mistral | `MISTRAL_API_KEY` | — | +| Deepseek | `DEEPSEEK_API_KEY` | — | +| Cerebras | `CEREBRAS_API_KEY` | — | + +Do not put API keys in config files. ## Using the MCP endpoint (@mcp) diff --git a/guillotina/contrib/mcp/chat.py b/guillotina/contrib/mcp/chat.py index fdc339c12..cdf63bfce 100644 --- a/guillotina/contrib/mcp/chat.py +++ b/guillotina/contrib/mcp/chat.py @@ -20,14 +20,30 @@ MAX_TOOL_ROUNDS = 10 -def _get_api_key_for_model(model: str) -> str: +def _get_litellm_credentials(model: str): + api_key = "" + api_base = None if model.startswith("openai/"): - return os.environ.get("OPENAI_API_KEY") or "" - if model.startswith("gemini/"): - return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or "" - if model.startswith("anthropic/"): - return os.environ.get("ANTHROPIC_API_KEY") or "" - return "" + api_key = os.environ.get("OPENAI_API_KEY") or "" + elif model.startswith("gemini/"): + api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or "" + elif model.startswith("anthropic/"): + api_key = os.environ.get("ANTHROPIC_API_KEY") or "" + elif model.startswith("groq/"): + api_key = os.environ.get("GROQ_API_KEY") or "" + elif model.startswith("openrouter/"): + api_key = os.environ.get("OPENROUTER_API_KEY") or "" + api_base = os.environ.get("OPENROUTER_API_BASE") or None + elif model.startswith("minimax/"): + api_key = os.environ.get("MINIMAX_API_KEY") or "" + api_base = os.environ.get("MINIMAX_API_BASE") or None + elif model.startswith("mistral/"): + api_key = os.environ.get("MISTRAL_API_KEY") or "" + elif model.startswith("deepseek/"): + api_key = os.environ.get("DEEPSEEK_API_KEY") or "" + elif model.startswith("cerebras/"): + api_key = os.environ.get("CEREBRAS_API_KEY") or "" + return api_key, api_base async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): @@ -60,7 +76,7 @@ async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): method="POST", permission="guillotina.mcp.Use", name="@chat", - summary="Chat with LLM using MCP tools (OpenAI, Gemini, Anthropic)", + summary="Chat with LLM using MCP tools (OpenAI, Gemini, Anthropic, Groq, OpenRouter, MiniMax, Mistral, Deepseek, Cerebras)", # noqa: E501 ) class Chat(Service): __body_required__ = False @@ -103,11 +119,13 @@ async def _run_chat(self, messages: list, chat_model: str, mcp_settings: dict): litellm = __import__("litellm", fromlist=["acompletion"]) acompletion = getattr(litellm, "acompletion") tools = get_all_chat_tools() - api_key = _get_api_key_for_model(chat_model) + api_key, api_base = _get_litellm_credentials(chat_model) backend = InProcessBackend() kwargs = {"model": chat_model, "messages": messages, "tools": tools} if api_key: kwargs["api_key"] = api_key + if api_base: + kwargs["api_base"] = api_base for _ in range(MAX_TOOL_ROUNDS): response = await acompletion(**kwargs) choice = response.choices[0] if response.choices else None From 6031e7dbcc0d532ca1afc23da8b20566171a60b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sat, 31 Jan 2026 08:51:42 +0100 Subject: [PATCH 18/36] feat: Implement security checks in InProcessBackend for content visibility --- guillotina/contrib/mcp/backend.py | 19 +-- guillotina/tests/test_mcp.py | 224 ++++++++++++++++++++++++++++-- 2 files changed, 222 insertions(+), 21 deletions(-) diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index d69e95d1c..4bb02cc6e 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -5,6 +5,7 @@ from guillotina.interfaces import IResource from guillotina.interfaces import IResourceSerializeToJson from guillotina.utils import get_object_by_uid +from guillotina.utils import get_security_policy from guillotina.utils import navigate_to import typing @@ -83,6 +84,8 @@ async def get_content( return {} else: return {} + if not get_security_policy().check_permission("guillotina.ViewContent", ob): + return {} serializer = get_multi_adapter((ob, request), IResourceSerializeToJson) return await serializer() @@ -112,15 +115,15 @@ async def list_children( if not IFolder.providedBy(container): return {"items": [], "items_total": 0} + if not get_security_policy().check_permission("guillotina.ViewContent", container): + return {"items": [], "items_total": 0} request = task_vars.request.get() - items_total = await container.async_len() - items = [] - total = 0 + policy = get_security_policy() + visible = [] async for name, child in container.async_items(): - if total >= _from + _size: - break - if total >= _from: + if policy.check_permission("guillotina.ViewContent", child): summary_serializer = get_multi_adapter((child, request), IResourceSerializeToJsonSummary) - items.append(await summary_serializer()) - total += 1 + visible.append(await summary_serializer()) + items_total = len(visible) + items = visible[_from : _from + _size] return {"items": items, "items_total": items_total} diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 0b0d96cd4..8f6225a43 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -1,13 +1,29 @@ +from contextlib import asynccontextmanager from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.contrib.mcp.backend import set_mcp_context +from guillotina.tests import utils +from guillotina.transactions import get_transaction from unittest.mock import AsyncMock from unittest.mock import MagicMock import json +import os import pytest import sys +NOT_POSTGRES = os.environ.get("DATABASE", "DUMMY") in ("cockroachdb", "DUMMY") +MCP_PG_CATALOG_SETTINGS = { + "applications": ["guillotina.contrib.mcp", "guillotina.contrib.catalog.pg"], + "load_utilities": { + "catalog": { + "provides": "guillotina.interfaces.ICatalogUtility", + "factory": "guillotina.contrib.catalog.pg.utility.PGSearchUtility", + } + }, +} + try: import mcp except ImportError: @@ -19,6 +35,21 @@ ] +@asynccontextmanager +async def _mcp_backend_context(requester): + request = utils.get_mocked_request(db=requester.db) + utils.login() + try: + async with requester.transaction(request): + txn = get_transaction() + root = await txn.manager.get_root() + container = await root.async_get("guillotina") + set_mcp_context(container) + yield InProcessBackend(), container + finally: + clear_mcp_context() + + @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_mcp_service_registered(container_requester): async with container_requester as requester: @@ -51,19 +82,14 @@ async def test_mcp_tools_list(container_requester): @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) -async def test_inprocess_backend_search_requires_context(container_requester): - backend = InProcessBackend() - clear_mcp_context() - with pytest.raises(RuntimeError, match="MCP context not set"): - await backend.search(None, {}) - - -@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) -async def test_inprocess_backend_rejects_string_context(container_requester): - backend = InProcessBackend() - clear_mcp_context() - with pytest.raises(RuntimeError, match="InProcessBackend requires IResource context"): - await backend.search("db/guillotina", {}) +async def test_inprocess_backend_requires_valid_context(container_requester): + async with container_requester: + clear_mcp_context() + backend = InProcessBackend() + with pytest.raises(RuntimeError, match="MCP context not set"): + await backend.search(None, {}) + with pytest.raises(RuntimeError, match="InProcessBackend requires IResource context"): + await backend.search("db/guillotina", {}) @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) @@ -158,3 +184,175 @@ async def test_chat_returns_content_with_mock(container_requester): sys.modules["litellm"] = orig_litellm elif "litellm" in sys.modules: del sys.modules["litellm"] + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_backend_get_content_by_path_and_uid_returns_serialized(container_requester): + async with container_requester as requester: + resp, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Item", "id": "myitem"}), + ) + assert status == 201 + uid = resp.get("@uid") + async with _mcp_backend_context(requester) as (backend, container): + by_path = await backend.get_content(container, "myitem", None) + by_uid = await backend.get_content(container, None, uid) + assert by_path.get("@id") and by_path.get("@type") == "Item" and by_path.get("@name") == "myitem" + assert by_uid.get("@id") == by_path.get("@id") and by_uid.get("@name") == "myitem" + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_backend_get_content_without_view_content_returns_empty(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Item", "id": "secret"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/secret/@sharing", + data=json.dumps( + { + "prinperm": [ + { + "principal": "root", + "permission": "guillotina.ViewContent", + "setting": "Deny", + } + ] + } + ), + ) + assert status == 200 + async with _mcp_backend_context(requester) as (backend, container): + result = await backend.get_content(container, "secret", None) + assert result == {} + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_backend_list_children_returns_visible_only(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Folder", "id": "folder"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/folder/", + data=json.dumps({"@type": "Item", "id": "visible"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/folder/", + data=json.dumps({"@type": "Item", "id": "hidden"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/folder/hidden/@sharing", + data=json.dumps( + { + "prinperm": [ + { + "principal": "root", + "permission": "guillotina.ViewContent", + "setting": "Deny", + } + ] + } + ), + ) + assert status == 200 + async with _mcp_backend_context(requester) as (backend, container): + result = await backend.list_children(container, "folder", 0, 20) + assert result["items_total"] == 1 + assert len(result["items"]) == 1 + assert result["items"][0]["@name"] == "visible" + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_backend_list_children_empty_when_no_view_on_container(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Folder", "id": "private"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/private/@sharing", + data=json.dumps( + { + "prinperm": [ + { + "principal": "root", + "permission": "guillotina.ViewContent", + "setting": "Deny", + } + ] + } + ), + ) + assert status == 200 + async with _mcp_backend_context(requester) as (backend, container): + result = await backend.list_children(container, "private", 0, 20) + assert result["items_total"] == 0 + assert result["items"] == [] + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_backend_search_with_context(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Item", "id": "searchme"}), + ) + assert status == 201 + async with _mcp_backend_context(requester) as (backend, container): + result = await backend.search(container, {}) + assert "items" in result + assert "items_total" in result + + +@pytest.mark.app_settings(MCP_PG_CATALOG_SETTINGS) +@pytest.mark.skipif(NOT_POSTGRES, reason="Search permission filtering uses PG catalog") +async def test_backend_search_respects_permissions(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Item", "id": "public_item"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Item", "id": "private_item"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/private_item/@sharing", + data=json.dumps( + { + "perminhe": [{"permission": "guillotina.AccessContent", "setting": "Deny"}], + "roleperm": [], + } + ), + ) + assert status == 200 + async with _mcp_backend_context(requester) as (backend, container): + result = await backend.search(container, {}) + names = [it.get("@name") for it in result.get("items", []) if it.get("@name")] + assert "private_item" not in names + assert "public_item" in names + assert result["items_total"] >= 1 From dabd2fab45fb1019db60dfa55d6002ed7846c177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Sun, 1 Feb 2026 21:48:56 +0100 Subject: [PATCH 19/36] feat: Refactor MCP backend and chat tool execution for improved context handling --- guillotina/contrib/mcp/backend.py | 12 +++-- guillotina/contrib/mcp/chat.py | 84 ++++++++++++++++++++----------- guillotina/contrib/mcp/tools.py | 20 ++++---- guillotina/tests/test_mcp.py | 31 ++++++++++++ 4 files changed, 103 insertions(+), 44 deletions(-) diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index 4bb02cc6e..ce5d3bdf4 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -119,11 +119,13 @@ async def list_children( return {"items": [], "items_total": 0} request = task_vars.request.get() policy = get_security_policy() - visible = [] + items = [] + items_total = 0 + start = _from if _from > 0 else 0 async for name, child in container.async_items(): if policy.check_permission("guillotina.ViewContent", child): - summary_serializer = get_multi_adapter((child, request), IResourceSerializeToJsonSummary) - visible.append(await summary_serializer()) - items_total = len(visible) - items = visible[_from : _from + _size] + if items_total >= start and len(items) < _size: + summary_serializer = get_multi_adapter((child, request), IResourceSerializeToJsonSummary) + items.append(await summary_serializer()) + items_total += 1 return {"items": items, "items_total": items_total} diff --git a/guillotina/contrib/mcp/chat.py b/guillotina/contrib/mcp/chat.py index cdf63bfce..0df11a6b4 100644 --- a/guillotina/contrib/mcp/chat.py +++ b/guillotina/contrib/mcp/chat.py @@ -2,6 +2,7 @@ from guillotina._settings import app_settings from guillotina.api.service import Service from guillotina.contrib.mcp.backend import clear_mcp_context +from guillotina.contrib.mcp.backend import get_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.backend import set_mcp_context from guillotina.contrib.mcp.tools import get_all_chat_tools @@ -46,21 +47,47 @@ def _get_litellm_credentials(model: str): return api_key, api_base +def _get_value(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +async def _context_for_path(container_path): + ctx = get_mcp_context() + if ctx is None: + return None + if container_path: + from guillotina.utils import navigate_to + + try: + return await navigate_to(ctx, "/" + container_path.strip("/")) + except KeyError: + return None + return ctx + + async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): args = arguments or {} + container_path = args.get("container_path") or None + context = await _context_for_path(container_path) if name == "search": - return await backend.search(None, args.get("query") or {}) + if context is None: + return {"items": [], "items_total": 0} + return await backend.search(context, args.get("query") or {}) if name == "count": - return await backend.count(None, args.get("query") or {}) + if context is None: + return 0 + return await backend.count(context, args.get("query") or {}) if name == "get_content": - return await backend.get_content( - None, - args.get("path"), - args.get("uid"), - ) + if context is None: + return {} + return await backend.get_content(context, args.get("path"), args.get("uid")) if name == "list_children": + if context is None: + return {"items": [], "items_total": 0} return await backend.list_children( - None, + context, args.get("path") or "", args.get("from_index", 0), args.get("page_size", 20), @@ -128,25 +155,28 @@ async def _run_chat(self, messages: list, chat_model: str, mcp_settings: dict): kwargs["api_base"] = api_base for _ in range(MAX_TOOL_ROUNDS): response = await acompletion(**kwargs) - choice = response.choices[0] if response.choices else None + choices = _get_value(response, "choices", None) or [] + choice = choices[0] if choices else None if not choice: raise HTTPPreconditionFailed(content={"reason": "Empty response from LLM"}) - msg = choice.message - tool_calls = getattr(msg, "tool_calls", None) or [] + msg = _get_value(choice, "message", None) + if msg is None: + raise HTTPPreconditionFailed(content={"reason": "Empty response from LLM"}) + tool_calls = _get_value(msg, "tool_calls", None) or [] + if isinstance(tool_calls, dict): + tool_calls = [tool_calls] + elif not isinstance(tool_calls, list): + tool_calls = [] if not tool_calls: - content = getattr(msg, "content", None) or "" + content = _get_value(msg, "content", None) or "" return {"content": content} - assistant_msg = {"role": "assistant", "content": getattr(msg, "content", None)} + assistant_msg = {"role": "assistant", "content": _get_value(msg, "content", None)} tool_calls_list = [] for tc in tool_calls: - tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else "") - fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else {}) - name = getattr(fn, "name", None) if fn else None - if name is None and isinstance(fn, dict): - name = fn.get("name", "") - raw_args = getattr(fn, "arguments", None) if fn else None - if raw_args is None and isinstance(fn, dict): - raw_args = fn.get("arguments", "{}") + tc_id = _get_value(tc, "id", "") or "" + fn = _get_value(tc, "function", None) or {} + name = _get_value(fn, "name", "") or "" + raw_args = _get_value(fn, "arguments", "{}") tool_calls_list.append( { "id": tc_id, @@ -157,14 +187,10 @@ async def _run_chat(self, messages: list, chat_model: str, mcp_settings: dict): assistant_msg["tool_calls"] = tool_calls_list messages.append(assistant_msg) for tc in tool_calls: - tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else "") - fn = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else {}) - name = getattr(fn, "name", None) if fn else None - if name is None and isinstance(fn, dict): - name = fn.get("name", "") - raw_args = getattr(fn, "arguments", None) if fn else None - if raw_args is None and isinstance(fn, dict): - raw_args = fn.get("arguments", "{}") + tc_id = _get_value(tc, "id", "") or "" + fn = _get_value(tc, "function", None) or {} + name = _get_value(fn, "name", "") or "" + raw_args = _get_value(fn, "arguments", "{}") name = name or "" raw_args = raw_args or "{}" try: diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index 53f64f5ad..86b5a3b51 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -77,7 +77,7 @@ def _get_description_extras(): def register_tools(mcp_server, backend): - def _context_for_path(container_path: typing.Optional[str]): + async def _context_for_path(container_path: typing.Optional[str]): ctx = get_mcp_context() if ctx is None: return None @@ -85,7 +85,7 @@ def _context_for_path(container_path: typing.Optional[str]): from guillotina.utils import navigate_to try: - return navigate_to(ctx, "/" + container_path.strip("/")) + return await navigate_to(ctx, "/" + container_path.strip("/")) except KeyError: return None return ctx @@ -97,8 +97,8 @@ async def search( container_path: typing.Optional[str] = None, query: typing.Optional[typing.Dict[str, str]] = None, ) -> dict: - context = _context_for_path(container_path) - if context is None and container_path is None: + context = await _context_for_path(container_path) + if context is None: return {"items": [], "items_total": 0} q = query or {} return await backend.search(context, q) @@ -108,8 +108,8 @@ async def count( container_path: typing.Optional[str] = None, query: typing.Optional[typing.Dict[str, str]] = None, ): - context = _context_for_path(container_path) - if context is None and container_path is None: + context = await _context_for_path(container_path) + if context is None: return 0 q = query or {} return await backend.count(context, q) @@ -122,8 +122,8 @@ async def get_content( uid: typing.Optional[str] = None, container_path: typing.Optional[str] = None, ) -> dict: - context = _context_for_path(container_path) - if context is None and container_path is None: + context = await _context_for_path(container_path) + if context is None: return {} return await backend.get_content(context, path, uid) @@ -136,8 +136,8 @@ async def list_children( page_size: int = 20, container_path: typing.Optional[str] = None, ) -> dict: - context = _context_for_path(container_path) - if context is None and container_path is None: + context = await _context_for_path(container_path) + if context is None: return {"items": [], "items_total": 0} return await backend.list_children(context, path or "", from_index, page_size) diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 8f6225a43..4b43ceed9 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -2,6 +2,7 @@ from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.backend import set_mcp_context +from guillotina.contrib.mcp.chat import _execute_tool from guillotina.tests import utils from guillotina.transactions import get_transaction from unittest.mock import AsyncMock @@ -308,6 +309,36 @@ async def test_backend_list_children_empty_when_no_view_on_container(container_r assert result["items"] == [] +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_chat_execute_tool_container_path_scopes(container_requester): + async with container_requester as requester: + _, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps({"@type": "Folder", "id": "sub"}), + ) + assert status == 201 + _, status = await requester( + "POST", + "/db/guillotina/sub/", + data=json.dumps({"@type": "Item", "id": "inside"}), + ) + assert status == 201 + async with _mcp_backend_context(requester) as (backend, _): + result = await _execute_tool(backend, "list_children", {"container_path": "sub"}) + assert result["items_total"] == 1 + assert result["items"][0]["@name"] == "inside" + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +async def test_chat_execute_tool_invalid_container_path_returns_empty(container_requester): + async with container_requester as requester: + async with _mcp_backend_context(requester) as (backend, _): + result = await _execute_tool(backend, "list_children", {"container_path": "missing"}) + assert result["items_total"] == 0 + assert result["items"] == [] + + @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) async def test_backend_search_with_context(container_requester): async with container_requester as requester: From 0ce4b9db2d853f3f41e86501e267be953264cb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 2 Feb 2026 07:39:11 +0100 Subject: [PATCH 20/36] chore: Update dependencies in requirements.txt and setup.py for improved compatibility and performance --- guillotina/metrics.py | 8 ++++---- requirements.txt | 14 +++++++------- setup.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/guillotina/metrics.py b/guillotina/metrics.py index d8512864f..682c74cb7 100644 --- a/guillotina/metrics.py +++ b/guillotina/metrics.py @@ -34,7 +34,7 @@ def __init__( self.error_mappings = error_mappings or {} def __enter__(self): - self.start = time.time() + self.start = time.perf_counter() return self def __exit__( @@ -48,7 +48,7 @@ def __exit__( error = ERROR_NONE if self.histogram is not None: - finished = time.time() + finished = time.perf_counter() if len(self.labels) > 0: self.histogram.labels(**self.labels).observe(finished - self.start) else: @@ -79,10 +79,10 @@ def __init__( self.labels = labels or {} async def __aenter__(self) -> None: - start = time.time() + start = time.perf_counter() await self.lock.acquire() if self.histogram is not None: - finished = time.time() + finished = time.perf_counter() if len(self.labels) > 0: self.histogram.labels(**self.labels).observe(finished - start) else: diff --git a/requirements.txt b/requirements.txt index 41d0ff7f9..001160962 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,22 @@ Cython==0.29.24 asyncpg>=0.28.0 -cffi==1.14.6 +cffi==2.0.0 chardet==3.0.4 -jsonschema==2.6.0 +jsonschema==4.26.0 multidict==6.0.4 pycparser==2.20 pycryptodome==3.6.6 -PyJWT~=2.1.0 +PyJWT~=2.11.0 python-dateutil==2.8.2 PyYaml>=5.1 six==1.11.0 orjson>=3,<4 zope.interface==5.1.0 -uvicorn==0.17.6 -argon2-cffi==18.3.0 +uvicorn==0.40.0 +argon2-cffi==25.1.0 backoff==1.10.0 prometheus-client==0.8.0 -typing_extensions==3.7.4.3 +typing_extensions==4.15.0 types-chardet==0.1.5 types-docutils==0.17.0 @@ -26,4 +26,4 @@ types-pytz==2021.1.2 types-PyYAML==5.4.6 types-setuptools==57.0.2 types-toml==0.1.5 -types-redis==4.3.21 +types-redis==4.3.21 \ No newline at end of file diff --git a/setup.py b/setup.py index f2be4575d..1bf579a2b 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ install_requires=[ "uvicorn", "websockets", - "jsonschema==2.6.0", + "jsonschema==4.26.0", "python-dateutil", "pycryptodome", "jwcrypto", From bf84b9665c69d23170dad51829e09e5a6e8bdf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 2 Feb 2026 07:41:57 +0100 Subject: [PATCH 21/36] refactor: Simplify type annotations in MCP backend for improved readability --- guillotina/contrib/mcp/backend.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index ce5d3bdf4..9d2a528e2 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -8,10 +8,10 @@ from guillotina.utils import get_security_policy from guillotina.utils import navigate_to -import typing +from typing import Any, Dict, List, Optional -_mcp_context_var: ContextVar[typing.Optional[IResource]] = ContextVar("mcp_context", default=None) +_mcp_context_var: ContextVar[Optional[IResource]] = ContextVar("mcp_context", default=None) def get_mcp_context(): @@ -36,7 +36,7 @@ def _get_base_context(self) -> IResource: raise RuntimeError("MCP context not set (not in @mcp request?)") return ctx - def _resolve_context(self, context: typing.Optional[IResource]) -> IResource: + def _resolve_context(self, context: Optional[IResource]) -> IResource: if context is None: return self._get_base_context() if not IResource.providedBy(context): @@ -62,8 +62,8 @@ async def count(self, context: IResource, query: dict) -> int: async def get_content( self, context: IResource, - path: typing.Optional[str], - uid: typing.Optional[str], + path: Optional[str], + uid: Optional[str], ) -> dict: from guillotina import task_vars @@ -119,7 +119,7 @@ async def list_children( return {"items": [], "items_total": 0} request = task_vars.request.get() policy = get_security_policy() - items = [] + items: List[Dict[str, Any]] = [] items_total = 0 start = _from if _from > 0 else 0 async for name, child in container.async_items(): From 1ee50752e944dd101d6541986f4d259662d109f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 2 Feb 2026 09:04:11 +0100 Subject: [PATCH 22/36] refactor: Organize type imports in MCP backend for clarity --- guillotina/contrib/mcp/backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/guillotina/contrib/mcp/backend.py b/guillotina/contrib/mcp/backend.py index 9d2a528e2..1160f5b3f 100644 --- a/guillotina/contrib/mcp/backend.py +++ b/guillotina/contrib/mcp/backend.py @@ -7,8 +7,10 @@ from guillotina.utils import get_object_by_uid from guillotina.utils import get_security_policy from guillotina.utils import navigate_to - -from typing import Any, Dict, List, Optional +from typing import Any +from typing import Dict +from typing import List +from typing import Optional _mcp_context_var: ContextVar[Optional[IResource]] = ContextVar("mcp_context", default=None) From 779fec48663394532d68eb12f0b2e668d397e6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 2 Feb 2026 09:08:25 +0100 Subject: [PATCH 23/36] chore: Update CI workflow to support Python versions 3.10, 3.11, 3.12, and 3.13 --- .github/workflows/continuous-integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 8b4950b0e..ac839d21a 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - name: Checkout the repository @@ -34,7 +34,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13'] database: ["DUMMY", "postgres", "cockroachdb"] db_schema: ["custom", "public"] exclude: From cc84e39ea9227a1805396733c705231ef9752b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 2 Feb 2026 09:09:59 +0100 Subject: [PATCH 24/36] chore: Update CI workflow to limit supported Python versions to 3.10 and 3.11 --- .github/workflows/continuous-integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index ac839d21a..154d23953 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11'] steps: - name: Checkout the repository @@ -34,7 +34,7 @@ jobs: strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11'] database: ["DUMMY", "postgres", "cockroachdb"] db_schema: ["custom", "public"] exclude: From e498d4f9236b6014bdc1d7a92dcda2c1e0885e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 11 Feb 2026 10:18:55 +0100 Subject: [PATCH 25/36] refactor: Update register_tools function to use InProcessBackend type annotation and adjust test assertions for consistent status checks --- guillotina/contrib/mcp/tools.py | 3 ++- guillotina/tests/test_mcp.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index 86b5a3b51..ccb4c4b62 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -1,5 +1,6 @@ from guillotina._settings import app_settings from guillotina.contrib.mcp.backend import get_mcp_context +from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras import typing @@ -76,7 +77,7 @@ def _get_description_extras(): return extras -def register_tools(mcp_server, backend): +def register_tools(mcp_server, backend: InProcessBackend): async def _context_for_path(container_path: typing.Optional[str]): ctx = get_mcp_context() if ctx is None: diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 4b43ceed9..2eadfa431 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -55,7 +55,7 @@ async def _mcp_backend_context(requester): async def test_mcp_service_registered(container_requester): async with container_requester as requester: resp, status = await requester("GET", "/db/guillotina/@mcp") - assert status in (200, 401, 404, 421) + assert status == 200 @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": False}}) @@ -77,7 +77,7 @@ async def test_mcp_tools_list(container_requester): "Content-Type": "application/json", }, ) - assert status in (200, 401, 421) + assert status == 200 if status == 200 and isinstance(resp, dict): assert "result" in resp or "error" in resp From 030b9a5a3a294cb228ca973787de955c12476571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Wed, 11 Feb 2026 10:23:52 +0100 Subject: [PATCH 26/36] test: Update MCP service tests to assert correct status codes for enabled and disabled states --- guillotina/tests/test_mcp.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 2eadfa431..158923744 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -51,11 +51,11 @@ async def _mcp_backend_context(requester): clear_mcp_context() -@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": True}}) async def test_mcp_service_registered(container_requester): async with container_requester as requester: resp, status = await requester("GET", "/db/guillotina/@mcp") - assert status == 200 + assert status == 421 @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": False}}) @@ -65,7 +65,7 @@ async def test_mcp_disabled_returns_404(container_requester): assert status == 404 -@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": True}}) async def test_mcp_tools_list(container_requester): async with container_requester as requester: resp, status = await requester( @@ -77,9 +77,7 @@ async def test_mcp_tools_list(container_requester): "Content-Type": "application/json", }, ) - assert status == 200 - if status == 200 and isinstance(resp, dict): - assert "result" in resp or "error" in resp + assert status == 421 @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) From 5261e3809f7acf252b7ea2bebd5ea60e2eb57b86 Mon Sep 17 00:00:00 2001 From: nil Date: Thu, 12 Feb 2026 13:35:13 +0100 Subject: [PATCH 27/36] adong locking --- guillotina/contrib/mcp/server.py | 39 ++++++++++++++++++------------ guillotina/contrib/mcp/services.py | 19 +++++++++------ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/guillotina/contrib/mcp/server.py b/guillotina/contrib/mcp/server.py index 901639885..8cda0826a 100644 --- a/guillotina/contrib/mcp/server.py +++ b/guillotina/contrib/mcp/server.py @@ -2,30 +2,37 @@ from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.tools import register_tools +import threading + _mcp_server = None _mcp_app = None +_mcp_server_init_lock = threading.Lock() def get_mcp_app_and_server(): global _mcp_server, _mcp_app if _mcp_app is not None: return _mcp_app, _mcp_server - from mcp.server.fastmcp import FastMCP - backend = InProcessBackend() - mcp = FastMCP( - "Guillotina MCP", - json_response=True, - stateless_http=True, - ) - if hasattr(mcp, "settings") and hasattr(mcp.settings, "streamable_http_path"): - mcp.settings.streamable_http_path = "/" - register_tools(mcp, backend) - extra_module = app_settings.get("mcp", {}).get("extra_tools_module") - if extra_module: - mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) - getattr(mod, "register_extra_tools")(mcp, backend) - _mcp_server = mcp - _mcp_app = mcp.streamable_http_app() + with _mcp_server_init_lock: + if _mcp_app is not None: + return _mcp_app, _mcp_server + from mcp.server.fastmcp import FastMCP + + backend = InProcessBackend() + mcp = FastMCP( + "Guillotina MCP", + json_response=True, + stateless_http=True, + ) + if hasattr(mcp, "settings") and hasattr(mcp.settings, "streamable_http_path"): + mcp.settings.streamable_http_path = "/" + register_tools(mcp, backend) + extra_module = app_settings.get("mcp", {}).get("extra_tools_module") + if extra_module: + mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) + getattr(mod, "register_extra_tools")(mcp, backend) + _mcp_server = mcp + _mcp_app = mcp.streamable_http_app() return _mcp_app, _mcp_server diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index e75dbf2a3..9fe55ff03 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -12,6 +12,7 @@ from guillotina.response import Response from guillotina.utils import get_authenticated_user +import asyncio import anyio import copy import json @@ -19,6 +20,7 @@ logger = logging.getLogger("guillotina") +_mcp_session_manager_lock = asyncio.Lock() @configure.service( @@ -46,14 +48,15 @@ async def mcp_service(context, request): scope["path"] = "/" scope["raw_path"] = b"/" app, server = get_mcp_app_and_server() - session_manager = server.session_manager - original_task_group = session_manager._task_group # Workaround: mcp lib does not expose task group. - async with anyio.create_task_group() as tg: - session_manager._task_group = tg - try: - await app(scope, request.receive, request.send) - finally: - session_manager._task_group = original_task_group + async with _mcp_session_manager_lock: + session_manager = server.session_manager + original_task_group = session_manager._task_group # Workaround: mcp lib does not expose task group. + async with anyio.create_task_group() as tg: + session_manager._task_group = tg + try: + await app(scope, request.receive, request.send) + finally: + session_manager._task_group = original_task_group finally: clear_mcp_context() # Response already sent via request.send(); return dummy so framework does not send again. From 50304fffec767266fcc64ade651e0654865bd83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:03:37 +0100 Subject: [PATCH 28/36] feat: Introduce MCPUtility for managing FastMCP server and app instances, enhancing utility integration in the MCP module --- guillotina/contrib/mcp/__init__.py | 7 ++++ guillotina/contrib/mcp/interfaces.py | 8 +++++ guillotina/contrib/mcp/services.py | 12 ++++--- .../contrib/mcp/{server.py => utility.py} | 34 +++++++++---------- 4 files changed, 39 insertions(+), 22 deletions(-) rename guillotina/contrib/mcp/{server.py => utility.py} (64%) diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index 8f8bd8efb..e63033796 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -11,6 +11,13 @@ "chat_enabled": True, "chat_model": None, }, + "load_utilities": { + "guillotina.mcp": { + "provides": "guillotina.contrib.mcp.interfaces.IMCPUtility", + "factory": "guillotina.contrib.mcp.utility.MCPUtility", + "settings": {}, + } + }, } diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index 0ade108ad..4bca5288e 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -1,6 +1,14 @@ +from zope.interface import Attribute from zope.interface import Interface +class IMCPUtility(Interface): + """MCP server utility providing the FastMCP app and server instances.""" + + server = Attribute("FastMCP server instance") + app = Attribute("ASGI app from streamable_http_app()") + + class IMCPDescriptionExtras(Interface): """Utility returning a dict mapping tool name to extra description text (appended to the base tool description for LLM context). diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 9fe55ff03..3872366f2 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -3,17 +3,18 @@ from guillotina.api.service import Service from guillotina.auth import authenticate_user from guillotina.auth.users import AnonymousUser +from guillotina.component import get_utility from guillotina.contrib.mcp.backend import clear_mcp_context from guillotina.contrib.mcp.backend import set_mcp_context -from guillotina.contrib.mcp.server import get_mcp_app_and_server +from guillotina.contrib.mcp.interfaces import IMCPUtility from guillotina.interfaces import IResource from guillotina.response import HTTPPreconditionFailed from guillotina.response import HTTPUnauthorized from guillotina.response import Response from guillotina.utils import get_authenticated_user -import asyncio import anyio +import asyncio import copy import json import logging @@ -47,10 +48,13 @@ async def mcp_service(context, request): scope = copy.copy(request.scope) # Only top-level keys modified; shallow copy sufficient. scope["path"] = "/" scope["raw_path"] = b"/" - app, server = get_mcp_app_and_server() + mcp_utility = get_utility(IMCPUtility) + app, server = mcp_utility.app, mcp_utility.server async with _mcp_session_manager_lock: session_manager = server.session_manager - original_task_group = session_manager._task_group # Workaround: mcp lib does not expose task group. + original_task_group = ( + session_manager._task_group + ) # Workaround: mcp lib does not expose task group. async with anyio.create_task_group() as tg: session_manager._task_group = tg try: diff --git a/guillotina/contrib/mcp/server.py b/guillotina/contrib/mcp/utility.py similarity index 64% rename from guillotina/contrib/mcp/server.py rename to guillotina/contrib/mcp/utility.py index 8cda0826a..22c268316 100644 --- a/guillotina/contrib/mcp/server.py +++ b/guillotina/contrib/mcp/utility.py @@ -1,25 +1,16 @@ from guillotina._settings import app_settings from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.contrib.mcp.interfaces import IMCPUtility from guillotina.contrib.mcp.tools import register_tools +from zope.interface import implementer -import threading - -_mcp_server = None -_mcp_app = None -_mcp_server_init_lock = threading.Lock() - - -def get_mcp_app_and_server(): - global _mcp_server, _mcp_app - if _mcp_app is not None: - return _mcp_app, _mcp_server - - with _mcp_server_init_lock: - if _mcp_app is not None: - return _mcp_app, _mcp_server +@implementer(IMCPUtility) +class MCPUtility: + def __init__(self, settings=None): from mcp.server.fastmcp import FastMCP + settings = settings or {} backend = InProcessBackend() mcp = FastMCP( "Guillotina MCP", @@ -33,6 +24,13 @@ def get_mcp_app_and_server(): if extra_module: mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) getattr(mod, "register_extra_tools")(mcp, backend) - _mcp_server = mcp - _mcp_app = mcp.streamable_http_app() - return _mcp_app, _mcp_server + self._server = mcp + self._app = mcp.streamable_http_app() + + @property + def server(self): + return self._server + + @property + def app(self): + return self._app From 8ba5d30d026288abff344e9502f2ed451228d624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:10:05 +0100 Subject: [PATCH 29/36] chore: Update contrib-requirements and setup.py to include MCP dependencies, and enhance CI workflow to support Python 3.8 and 3.9 --- .github/workflows/continuous-integration.yml | 6 ++++-- contrib-requirements.txt | 2 -- docs/source/contrib/mcp.md | 11 +++++------ setup.py | 4 ++++ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 154d23953..8f2c31059 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11'] + python-version: [3.8, 3.9, '3.10', '3.11'] steps: - name: Checkout the repository @@ -34,7 +34,7 @@ jobs: strategy: matrix: - python-version: ['3.10', '3.11'] + python-version: [3.8, 3.9, '3.10', '3.11'] database: ["DUMMY", "postgres", "cockroachdb"] db_schema: ["custom", "public"] exclude: @@ -48,6 +48,7 @@ jobs: DATABASE: ${{ matrix.database }} DB_SCHEMA: ${{ matrix.db_schema }} MEMCACHED: "localhost:11211" + DOCKER_API_VERSION: "1.44" steps: - name: Checkout the repository @@ -65,6 +66,7 @@ jobs: pip install -r contrib-requirements.txt pip install -e .[test] pip install -e .[testdata] + if [[ "${{ matrix.python-version }}" == "3.10" || "${{ matrix.python-version }}" == "3.11" ]]; then pip install -e .[mcp]; fi - name: Start memcached image uses: niden/actions-memcached@v7 diff --git a/contrib-requirements.txt b/contrib-requirements.txt index 6c34c3a97..68eb8ab07 100644 --- a/contrib-requirements.txt +++ b/contrib-requirements.txt @@ -17,5 +17,3 @@ pymemcache==3.4.0; python_version < '3.10' # Conditional Pillow versions pillow==10.4.0; python_version < '3.11' pillow==11.1.0; python_version >= '3.11' -litellm>=1.0.0 -mcp>=1.0.0; python_version >= "3.10" diff --git a/docs/source/contrib/mcp.md b/docs/source/contrib/mcp.md index 0a8721f6a..6d06403b5 100644 --- a/docs/source/contrib/mcp.md +++ b/docs/source/contrib/mcp.md @@ -11,16 +11,15 @@ Both use the same read-only tools and the same permissions. ## Installation -**Requires Python 3.10+** (the `mcp` package does not support older versions). +**Requires Python 3.10+** (the `mcp` package does not support older versions). Guillotina core supports Python 3.8+; MCP is optional and only needed when you use this contrib. -1. Add dependencies (e.g. in `contrib-requirements.txt` or `requirements.txt`): +1. Install the MCP extra (Python 3.10+ only): - ``` - mcp>=1.0.0; python_version >= "3.10" - litellm>=1.0.0 + ```bash + pip install guillotina[mcp] ``` - `litellm` is required only if you use **@chat**. + Or add to your requirements: ``guillotina[mcp]`` or ``mcp>=1.0.0; python_version >= "3.10"`` and ``litellm>=1.0.0``. `litellm` is required only if you use **@chat**. 2. Enable the contrib in your app config: diff --git a/setup.py b/setup.py index 1bf579a2b..ac5c4a5f9 100644 --- a/setup.py +++ b/setup.py @@ -111,6 +111,10 @@ "memcached": ["emcache"], "validation": ["pytz==2020.1"], "recaptcha": ["aiohttp<4"], + "mcp": [ + "mcp>=1.0.0; python_version >= '3.10'", + "litellm>=1.0.0", + ], }, entry_points={ "console_scripts": [ From bb43682a1f1b0f5d65917195a7d4851bee2640bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:15:25 +0100 Subject: [PATCH 30/36] chore: Update requirements.txt to specify version ranges for cffi and argon2-cffi based on Python version compatibility --- requirements.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 001160962..d1008e10c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Cython==0.29.24 asyncpg>=0.28.0 -cffi==2.0.0 +cffi>=1.17.1,<2.0; python_version < '3.9' +cffi>=2.0.0; python_version >= '3.9' chardet==3.0.4 jsonschema==4.26.0 multidict==6.0.4 @@ -13,7 +14,8 @@ six==1.11.0 orjson>=3,<4 zope.interface==5.1.0 uvicorn==0.40.0 -argon2-cffi==25.1.0 +argon2-cffi>=23.1.0,<25.1.0; python_version < '3.8' +argon2-cffi>=25.1.0; python_version >= '3.8' backoff==1.10.0 prometheus-client==0.8.0 typing_extensions==4.15.0 From 8b306b05902a62276ffbf0f5a2361cf36223ad7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:17:30 +0100 Subject: [PATCH 31/36] chore: Update jsonschema version specifications in requirements.txt to accommodate Python version compatibility --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d1008e10c..67768d4af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,8 @@ asyncpg>=0.28.0 cffi>=1.17.1,<2.0; python_version < '3.9' cffi>=2.0.0; python_version >= '3.9' chardet==3.0.4 -jsonschema==4.26.0 +jsonschema>=4.23.0,<4.24; python_version < '3.10' +jsonschema>=4.26.0; python_version >= '3.10' multidict==6.0.4 pycparser==2.20 pycryptodome==3.6.6 From 5d37add970de0ba13b618fa4b7938a90e5bf91eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:19:00 +0100 Subject: [PATCH 32/36] chore: Adjust PyJWT version specifications in requirements.txt for improved Python version compatibility --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 67768d4af..5e127fdbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,8 @@ jsonschema>=4.26.0; python_version >= '3.10' multidict==6.0.4 pycparser==2.20 pycryptodome==3.6.6 -PyJWT~=2.11.0 +PyJWT>=2.8.0,<2.10; python_version < '3.9' +PyJWT~=2.11.0; python_version >= '3.9' python-dateutil==2.8.2 PyYaml>=5.1 six==1.11.0 From e76c321dc06034c1499911a15b44771dfb48a6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 13 Feb 2026 23:23:30 +0100 Subject: [PATCH 33/36] chore: Refine version specifications for uvicorn, jsonschema, cffi, argon2-cffi, pyjwt, and typing_extensions in requirements.txt and setup.py to enhance compatibility across Python versions --- requirements.txt | 8 +++++--- setup.py | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5e127fdbb..ea5d4e90f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ cffi>=1.17.1,<2.0; python_version < '3.9' cffi>=2.0.0; python_version >= '3.9' chardet==3.0.4 jsonschema>=4.23.0,<4.24; python_version < '3.10' -jsonschema>=4.26.0; python_version >= '3.10' +jsonschema==4.26.0; python_version >= '3.10' multidict==6.0.4 pycparser==2.20 pycryptodome==3.6.6 @@ -15,12 +15,14 @@ PyYaml>=5.1 six==1.11.0 orjson>=3,<4 zope.interface==5.1.0 -uvicorn==0.40.0 +uvicorn>=0.33.0,<0.40; python_version < '3.10' +uvicorn==0.40.0; python_version >= '3.10' argon2-cffi>=23.1.0,<25.1.0; python_version < '3.8' argon2-cffi>=25.1.0; python_version >= '3.8' backoff==1.10.0 prometheus-client==0.8.0 -typing_extensions==4.15.0 +typing_extensions>=4.13.0,<4.14; python_version < '3.9' +typing_extensions==4.15.0; python_version >= '3.9' types-chardet==0.1.5 types-docutils==0.17.0 diff --git a/setup.py b/setup.py index ac5c4a5f9..265e64040 100644 --- a/setup.py +++ b/setup.py @@ -55,25 +55,31 @@ package_data={"": ["*.txt", "*.rst", "guillotina/documentation/meta/*.json"], "guillotina": ["py.typed"]}, packages=find_packages(), install_requires=[ - "uvicorn", + "uvicorn>=0.33.0,<0.40; python_version < '3.10'", + "uvicorn==0.40.0; python_version >= '3.10'", "websockets", - "jsonschema==4.26.0", + "jsonschema>=4.23.0,<4.24; python_version < '3.10'", + "jsonschema==4.26.0; python_version >= '3.10'", "python-dateutil", "pycryptodome", "jwcrypto", "setuptools", "orjson>=3,<4", "zope.interface", - "pyjwt", + "pyjwt>=2.8.0,<2.10; python_version < '3.9'", + "pyjwt~=2.11.0; python_version >= '3.9'", "asyncpg", - "cffi", + "cffi>=1.17.1,<2.0; python_version < '3.9'", + "cffi>=2.0.0; python_version >= '3.9'", "PyYAML>=5.1", "lru-dict", "mypy_extensions", - "argon2-cffi", + "argon2-cffi>=23.1.0,<25.1.0; python_version < '3.8'", + "argon2-cffi>=25.1.0; python_version >= '3.8'", "backoff", "multidict", - "typing_extensions", + "typing_extensions>=4.13.0,<4.14; python_version < '3.9'", + "typing_extensions==4.15.0; python_version >= '3.9'", "watchfiles>=0.16.1", ], extras_require={ From d8d4e2e3cc1795090f2c7f5fa96650013eefc9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 16 Feb 2026 14:40:06 +0100 Subject: [PATCH 34/36] feat: Add lifespan management for MCP utility and normalize query handling in chat tools --- guillotina/contrib/mcp/__init__.py | 1 + guillotina/contrib/mcp/chat.py | 5 ++-- guillotina/contrib/mcp/lifespan.py | 29 ++++++++++++++++++++++ guillotina/contrib/mcp/services.py | 18 ++------------ guillotina/contrib/mcp/tools.py | 39 +++++++++++++++++++++--------- guillotina/contrib/mcp/utility.py | 6 +++++ guillotina/tests/test_mcp.py | 15 ++++++++++++ 7 files changed, 83 insertions(+), 30 deletions(-) create mode 100644 guillotina/contrib/mcp/lifespan.py diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index e63033796..d14c314dc 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -23,5 +23,6 @@ def includeme(root, settings): configure.scan("guillotina.contrib.mcp.permissions") + configure.scan("guillotina.contrib.mcp.lifespan") configure.scan("guillotina.contrib.mcp.services") configure.scan("guillotina.contrib.mcp.chat") diff --git a/guillotina/contrib/mcp/chat.py b/guillotina/contrib/mcp/chat.py index 0df11a6b4..18928a140 100644 --- a/guillotina/contrib/mcp/chat.py +++ b/guillotina/contrib/mcp/chat.py @@ -5,6 +5,7 @@ from guillotina.contrib.mcp.backend import get_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.backend import set_mcp_context +from guillotina.contrib.mcp.tools import _normalize_query from guillotina.contrib.mcp.tools import get_all_chat_tools from guillotina.contrib.mcp.tools import get_extra_tools_module from guillotina.interfaces import IResource @@ -74,11 +75,11 @@ async def _execute_tool(backend: InProcessBackend, name: str, arguments: dict): if name == "search": if context is None: return {"items": [], "items_total": 0} - return await backend.search(context, args.get("query") or {}) + return await backend.search(context, _normalize_query(args.get("query"))) if name == "count": if context is None: return 0 - return await backend.count(context, args.get("query") or {}) + return await backend.count(context, _normalize_query(args.get("query"))) if name == "get_content": if context is None: return {} diff --git a/guillotina/contrib/mcp/lifespan.py b/guillotina/contrib/mcp/lifespan.py new file mode 100644 index 000000000..986908b56 --- /dev/null +++ b/guillotina/contrib/mcp/lifespan.py @@ -0,0 +1,29 @@ +from contextlib import AsyncExitStack +from guillotina import configure +from guillotina.component import ComponentLookupError +from guillotina.component import get_utility +from guillotina.contrib.mcp.interfaces import IMCPUtility +from guillotina.events import ApplicationInitializedEvent + +import logging + + +logger = logging.getLogger("guillotina") + + +@configure.subscriber(for_=ApplicationInitializedEvent) +async def mcp_lifespan_startup(event): + try: + mcp_utility = get_utility(IMCPUtility) + except ComponentLookupError: + return + session_manager = mcp_utility.server.session_manager + exit_stack = AsyncExitStack() + await exit_stack.enter_async_context(session_manager.run()) + + async def cleanup(_app): + await exit_stack.aclose() + logger.info("MCP session manager stopped (lifespan)") + + event.app.on_cleanup.insert(0, cleanup) + logger.info("MCP session manager started (lifespan)") diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 3872366f2..6fcdaa882 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -13,15 +13,12 @@ from guillotina.response import Response from guillotina.utils import get_authenticated_user -import anyio -import asyncio import copy import json import logging logger = logging.getLogger("guillotina") -_mcp_session_manager_lock = asyncio.Lock() @configure.service( @@ -45,22 +42,11 @@ async def mcp_service(context, request): raise HTTPNotFound(content={"reason": "MCP is disabled"}) set_mcp_context(context) try: - scope = copy.copy(request.scope) # Only top-level keys modified; shallow copy sufficient. + scope = copy.copy(request.scope) scope["path"] = "/" scope["raw_path"] = b"/" mcp_utility = get_utility(IMCPUtility) - app, server = mcp_utility.app, mcp_utility.server - async with _mcp_session_manager_lock: - session_manager = server.session_manager - original_task_group = ( - session_manager._task_group - ) # Workaround: mcp lib does not expose task group. - async with anyio.create_task_group() as tg: - session_manager._task_group = tg - try: - await app(scope, request.receive, request.send) - finally: - session_manager._task_group = original_task_group + await mcp_utility.app(scope, request.receive, request.send) finally: clear_mcp_context() # Response already sent via request.send(); return dummy so framework does not send again. diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index ccb4c4b62..13ad4afcf 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -3,20 +3,19 @@ from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras +import json import typing TOOL_DESCRIPTIONS = { "search": ( "Search the catalog. container_path is optional (default: current context). " - "query keys follow Guillotina @search: type_name, term, _size, _from, _sort_asc " - "(field name for ascending), _sort_des (field name for descending), _metadata, " - "_metadata_not; field filters: field__eq, field__not, field__gt, field__gte, " - "field__lt, field__lte, field__in. E.g. creators__in to filter by creator." + "query must be an object (dict) with keys: type_name, term, _size, _from, _sort_asc, _sort_des; " + "date filters: fieldname__gte, fieldname__lte (ISO 8601, e.g. 2026-02-09T00:00:00Z)." ), "count": ( - "Count catalog results. container_path is optional. query uses same keys as search: " - "type_name, term, field__eq, field__gt, etc. (no _size/_from/_sort_asc/_sort_des)." + "Count catalog results. container_path is optional. query must be an object (dict), same keys as search " + "(no _size/_from/_sort_asc/_sort_des). Date filters: fieldname__gte, fieldname__lte (ISO 8601)." ), "get_content": ( "Get a resource by path (relative to container) or by UID. " @@ -35,7 +34,7 @@ "container_path": {"type": "string", "description": "Optional path relative to container."}, "query": { "type": "object", - "description": "Search query: type_name, term, _size, _from, _sort_asc, _sort_des, field filters.", + "description": "Search query object. Keys: type_name, _size, fieldname__gte, fieldname__lte (dates ISO 8601).", # noqa: E501 }, }, }, @@ -44,7 +43,7 @@ "container_path": {"type": "string", "description": "Optional path relative to container."}, "query": { "type": "object", - "description": "Count query: type_name, term, field filters (no _size/_from/_sort).", + "description": "Count query object. Keys: type_name, fieldname__gte, fieldname__lte (dates ISO 8601).", }, }, }, @@ -66,6 +65,22 @@ } +def _normalize_query( + query: typing.Optional[typing.Union[typing.Dict[str, typing.Any], str]], +) -> typing.Dict[str, typing.Any]: + if query is None: + return {} + if isinstance(query, dict): + return query + if isinstance(query, str): + try: + parsed = json.loads(query) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + return {} + + def _get_description_extras(): from guillotina.component import query_utility @@ -96,23 +111,23 @@ async def _context_for_path(container_path: typing.Optional[str]): @mcp_server.tool(description=(TOOL_DESCRIPTIONS["search"] + " " + (extras.get("search") or "")).strip()) async def search( container_path: typing.Optional[str] = None, - query: typing.Optional[typing.Dict[str, str]] = None, + query: typing.Optional[typing.Dict[str, typing.Any]] = None, ) -> dict: context = await _context_for_path(container_path) if context is None: return {"items": [], "items_total": 0} - q = query or {} + q = _normalize_query(query) return await backend.search(context, q) @mcp_server.tool(description=(TOOL_DESCRIPTIONS["count"] + " " + (extras.get("count") or "")).strip()) async def count( container_path: typing.Optional[str] = None, - query: typing.Optional[typing.Dict[str, str]] = None, + query: typing.Optional[typing.Dict[str, typing.Any]] = None, ): context = await _context_for_path(container_path) if context is None: return 0 - q = query or {} + q = _normalize_query(query) return await backend.count(context, q) @mcp_server.tool( diff --git a/guillotina/contrib/mcp/utility.py b/guillotina/contrib/mcp/utility.py index 22c268316..4f48028d1 100644 --- a/guillotina/contrib/mcp/utility.py +++ b/guillotina/contrib/mcp/utility.py @@ -7,6 +7,12 @@ @implementer(IMCPUtility) class MCPUtility: + async def initialize(self, app): + pass + + async def finalize(self, app): + pass + def __init__(self, settings=None): from mcp.server.fastmcp import FastMCP diff --git a/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py index 158923744..b0f16917c 100644 --- a/guillotina/tests/test_mcp.py +++ b/guillotina/tests/test_mcp.py @@ -3,6 +3,7 @@ from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.backend import set_mcp_context from guillotina.contrib.mcp.chat import _execute_tool +from guillotina.contrib.mcp.tools import _normalize_query from guillotina.tests import utils from guillotina.transactions import get_transaction from unittest.mock import AsyncMock @@ -51,6 +52,20 @@ async def _mcp_backend_context(requester): clear_mcp_context() +def test_normalize_query_accepts_dict_and_string(): + assert _normalize_query(None) == {} + assert _normalize_query({}) == {} + assert _normalize_query({"type_name": "Activity", "_size": 20}) == { + "type_name": "Activity", + "_size": 20, + } + assert _normalize_query('{"type_name": "Activity", "_size": 100}') == { + "type_name": "Activity", + "_size": 100, + } + assert _normalize_query("invalid") == {} + + @pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": True}}) async def test_mcp_service_registered(container_requester): async with container_requester as requester: From 1d99968b0a75eeb588785cf2ef44cad55d9bd826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Mon, 16 Feb 2026 19:34:47 +0100 Subject: [PATCH 35/36] refactor: Enhance MCP lifespan management by implementing asynchronous session handling and cleanup process --- guillotina/contrib/mcp/lifespan.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/guillotina/contrib/mcp/lifespan.py b/guillotina/contrib/mcp/lifespan.py index 986908b56..bc729b92e 100644 --- a/guillotina/contrib/mcp/lifespan.py +++ b/guillotina/contrib/mcp/lifespan.py @@ -1,10 +1,10 @@ -from contextlib import AsyncExitStack from guillotina import configure from guillotina.component import ComponentLookupError from guillotina.component import get_utility from guillotina.contrib.mcp.interfaces import IMCPUtility from guillotina.events import ApplicationInitializedEvent +import asyncio import logging @@ -18,11 +18,30 @@ async def mcp_lifespan_startup(event): except ComponentLookupError: return session_manager = mcp_utility.server.session_manager - exit_stack = AsyncExitStack() - await exit_stack.enter_async_context(session_manager.run()) + ready = asyncio.Event() + stop = asyncio.Event() + startup_exc = None + + async def _run_session_manager(): + nonlocal startup_exc + try: + async with session_manager.run(): + ready.set() + await stop.wait() + except Exception as exc: + startup_exc = exc + ready.set() + raise + + manager_task = asyncio.create_task(_run_session_manager()) + await ready.wait() + if startup_exc is not None: + await asyncio.gather(manager_task, return_exceptions=True) + raise startup_exc async def cleanup(_app): - await exit_stack.aclose() + stop.set() + await manager_task logger.info("MCP session manager stopped (lifespan)") event.app.on_cleanup.insert(0, cleanup) From 10a654039e73216b9a7a2aa4538eef59b5519b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Boixader=20G=C3=BCell?= Date: Fri, 27 Feb 2026 10:27:46 +0100 Subject: [PATCH 36/36] wip: Mcp --- guillotina/contrib/mcp/__init__.py | 1 + guillotina/contrib/mcp/interfaces.py | 16 +- guillotina/contrib/mcp/lifespan.py | 2 +- guillotina/contrib/mcp/lifespan_explicacio.md | 109 ++++++++++++ guillotina/contrib/mcp/services.py | 110 ++++++++++-- guillotina/contrib/mcp/tools.py | 163 +++++++++++------- guillotina/contrib/mcp/utility.py | 63 +++++-- 7 files changed, 370 insertions(+), 94 deletions(-) create mode 100644 guillotina/contrib/mcp/lifespan_explicacio.md diff --git a/guillotina/contrib/mcp/__init__.py b/guillotina/contrib/mcp/__init__.py index d14c314dc..3efd96902 100644 --- a/guillotina/contrib/mcp/__init__.py +++ b/guillotina/contrib/mcp/__init__.py @@ -23,6 +23,7 @@ def includeme(root, settings): configure.scan("guillotina.contrib.mcp.permissions") + configure.scan("guillotina.contrib.mcp.tools") configure.scan("guillotina.contrib.mcp.lifespan") configure.scan("guillotina.contrib.mcp.services") configure.scan("guillotina.contrib.mcp.chat") diff --git a/guillotina/contrib/mcp/interfaces.py b/guillotina/contrib/mcp/interfaces.py index 4bca5288e..c4aeb1eb7 100644 --- a/guillotina/contrib/mcp/interfaces.py +++ b/guillotina/contrib/mcp/interfaces.py @@ -3,10 +3,20 @@ class IMCPUtility(Interface): - """MCP server utility providing the FastMCP app and server instances.""" + """MCP utility providing low-level server and session manager.""" - server = Attribute("FastMCP server instance") - app = Attribute("ASGI app from streamable_http_app()") + server = Attribute("Low-level MCP server instance") + session_manager = Attribute("Streamable HTTPSessionManager instance") + + +class IMCPToolProvider(Interface): + """Named utility that exposes one MCP tool.""" + + def get_tool_definition(): + """Return dict with name, description and input_schema.""" + + async def execute(arguments): + """Execute tool call and return dict.""" class IMCPDescriptionExtras(Interface): diff --git a/guillotina/contrib/mcp/lifespan.py b/guillotina/contrib/mcp/lifespan.py index bc729b92e..855768f74 100644 --- a/guillotina/contrib/mcp/lifespan.py +++ b/guillotina/contrib/mcp/lifespan.py @@ -17,7 +17,7 @@ async def mcp_lifespan_startup(event): mcp_utility = get_utility(IMCPUtility) except ComponentLookupError: return - session_manager = mcp_utility.server.session_manager + session_manager = mcp_utility.session_manager ready = asyncio.Event() stop = asyncio.Event() startup_exc = None diff --git a/guillotina/contrib/mcp/lifespan_explicacio.md b/guillotina/contrib/mcp/lifespan_explicacio.md new file mode 100644 index 000000000..a5605e0c1 --- /dev/null +++ b/guillotina/contrib/mcp/lifespan_explicacio.md @@ -0,0 +1,109 @@ +# Explicació del mòdul lifespan MCP + +Aquest document explica el funcionament del fitxer `lifespan.py` del contrib MCP de Guillotina. + +## Què fa? + +El mòdul gestiona el **cicle de vida del session manager MCP**: l'arrenca quan Guillotina s'inicialitza i l'atura de forma ordenada quan l'aplicació es tanca. + +## Quan s'executa? + +La funció `mcp_lifespan_startup` està registrada com a subscriber de `ApplicationInitializedEvent`. Això vol dir que s'executa automàticament quan Guillotina ha acabat d'inicialitzar-se. + +## Flux del codi + +### 1. Comprovació de MCP + +```python +try: + mcp_utility = get_utility(IMCPUtility) +except ComponentLookupError: + return +``` + +Si MCP no està configurat (no hi ha cap utility `IMCPUtility` registrada), la funció surt sense fer res. Això permet que Guillotina funcioni sense MCP sense errors. + +### 2. Variables de coordinació + +```python +ready = asyncio.Event() +stop = asyncio.Event() +startup_exc = None +``` + +- **ready**: S'utilitza per indicar quan el session manager ha arrencat. El codi principal espera aquest senyal abans de continuar. +- **stop**: S'utilitza per indicar al session manager que s'ha d'aturar. El session manager espera aquest senyal dins del seu bucle. +- **startup_exc**: Guarda qualsevol excepció que es produeixi durant l'arrencada, per poder-la propagar després. + +### 3. La funció _run_session_manager + +Aquesta funció s'executa en una tasca en segon pla (background task). El que fa: + +1. Entra al context manager `session_manager.run()` — aquí el session manager arrenca. +2. Crida `ready.set()` — notifica al codi principal que ja ha arrencat. +3. Espera amb `await stop.wait()` — queda bloquejat fins que algú cridi `stop.set()` (quan Guillotina es tanca). +4. Quan es crida `stop.set()`, surt del `await stop.wait()` i el context manager es tanca correctament. + +### 4. Gestió d'excepcions + +Si hi ha una excepció dins de `session_manager.run()` (per exemple, en entrar al context manager): + +```python +except Exception as exc: + startup_exc = exc + ready.set() + raise +``` + +**Per què `ready.set()` dins de l'except?** Per evitar un deadlock: + +- El codi principal fa `await ready.wait()` i queda bloquejat. +- Si l'excepció es produeix abans de `ready.set()`, mai es cridaria `ready.set()`. +- Sense `ready.set()`, el codi principal restaria bloquejat indefinidament. + +En cridar `ready.set()` dins de l'except, assegurem que el codi principal sempre es desbloquegi, tant si tot va bé com si hi ha error. + +**Per què guardar l'excepció a `startup_exc`?** Perquè el codi principal pugui detectar que ha fallat i propagar l'error a Guillotina. Així l'aplicació pot saber que el MCP no ha pogut arrencar i actuar en conseqüència. + +### 5. Esperar l'arrencada + +```python +manager_task = asyncio.create_task(_run_session_manager()) +await ready.wait() +``` + +Es crea la tasca en segon pla i el codi principal espera que el session manager arrenqui (o que hi hagi un error, que també faria `ready.set()`). + +### 6. Si hi ha hagut error + +```python +if startup_exc is not None: + await asyncio.gather(manager_task, return_exceptions=True) + raise startup_exc +``` + +- `asyncio.gather(..., return_exceptions=True)` espera que la tasca acabi i absorbeix l'excepció (no la propaga). +- Després es fa `raise startup_exc` per propagar l'error a qui ha cridat `mcp_lifespan_startup`. + +### 7. Registre del cleanup + +```python +async def cleanup(_app): + stop.set() + await manager_task + logger.info("MCP session manager stopped (lifespan)") + +event.app.on_cleanup.insert(0, cleanup) +``` + +La funció `cleanup` es registra a `on_cleanup` de l'aplicació. Quan Guillotina es tanca, s'executen totes les funcions de cleanup. El nostre cleanup: + +1. Crida `stop.set()` — el session manager que esperava a `stop.wait()` es desbloqueja i pot tancar-se. +2. Espera que `manager_task` acabi amb `await manager_task`. +3. Registra un missatge al log. + +Es fa `insert(0, cleanup)` perquè el nostre cleanup s'executi entre els primers (prioritat alta), assegurant que el session manager MCP s'aturi abans que altres recursos. + +## Resum + +El mòdul connecta el cicle de vida de Guillotina amb el del session manager MCP: l'arrenca en paral·lel quan l'app s'inicialitza i l'atura de forma ordenada quan l'app es tanca. Els events `ready` i `stop` permeten coordinar correctament entre el codi principal i la tasca en segon pla, i la gestió d'excepcions evita deadlocks quan hi ha errors d'arrencada. diff --git a/guillotina/contrib/mcp/services.py b/guillotina/contrib/mcp/services.py index 6fcdaa882..f470168da 100644 --- a/guillotina/contrib/mcp/services.py +++ b/guillotina/contrib/mcp/services.py @@ -12,7 +12,9 @@ from guillotina.response import HTTPUnauthorized from guillotina.response import Response from guillotina.utils import get_authenticated_user +from multidict import CIMultiDict +import anyio import copy import json import logging @@ -21,39 +23,125 @@ logger = logging.getLogger("guillotina") +def _ensure_mcp_enabled(): + if not app_settings.get("mcp", {}).get("enabled", True): + from guillotina.response import HTTPNotFound + + raise HTTPNotFound(content={"reason": "MCP is disabled"}) + + +def _make_dummy_response() -> Response: + resp = Response() + resp._prepared = True + resp._eof_sent = True + return resp + + @configure.service( context=IResource, method="POST", permission="guillotina.mcp.Use", name="@mcp", - summary="MCP protocol endpoint (POST)", + summary="MCP protocol endpoint (POST, captured response)", ) @configure.service( context=IResource, method="GET", permission="guillotina.mcp.Use", name="@mcp", - summary="MCP protocol endpoint (GET)", + summary="MCP protocol endpoint (GET, captured response)", ) async def mcp_service(context, request): - if not app_settings.get("mcp", {}).get("enabled", True): - from guillotina.response import HTTPNotFound + _ensure_mcp_enabled() + set_mcp_context(context) + try: + scope = copy.copy(request.scope) + scope["path"] = "/" + scope["raw_path"] = b"/" + mcp_utility = get_utility(IMCPUtility) - raise HTTPNotFound(content={"reason": "MCP is disabled"}) + response_status = 200 + response_headers = [] + response_body = bytearray() + + async def capture_send(message): + nonlocal response_status + if message["type"] == "http.response.start": + response_status = message.get("status", 200) + response_headers[:] = list(message.get("headers", [])) + return + if message["type"] == "http.response.body": + body = message.get("body", b"") + if body: + response_body.extend(body) + + await mcp_utility.session_manager.handle_request(scope, request.receive, capture_send) + + headers = CIMultiDict() + for key, value in response_headers: + header_key = key.decode() if isinstance(key, (bytes, bytearray)) else str(key) + header_value = value.decode() if isinstance(value, (bytes, bytearray)) else str(value) + headers.add(header_key, header_value) + + return Response(body=bytes(response_body), headers=headers, status=response_status) + finally: + clear_mcp_context() + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.mcp.Use", + name="@mcp-legacy", + summary="MCP protocol endpoint (POST, passthrough dummy response)", +) +@configure.service( + context=IResource, + method="GET", + permission="guillotina.mcp.Use", + name="@mcp-legacy", + summary="MCP protocol endpoint (GET, passthrough dummy response)", +) +async def mcp_legacy_service(context, request): + _ensure_mcp_enabled() set_mcp_context(context) try: + from mcp.server.streamable_http import StreamableHTTPServerTransport + scope = copy.copy(request.scope) scope["path"] = "/" scope["raw_path"] = b"/" mcp_utility = get_utility(IMCPUtility) - await mcp_utility.app(scope, request.receive, request.send) + + http_transport = StreamableHTTPServerTransport( + mcp_session_id=None, + is_json_response_enabled=True, + event_store=None, + security_settings=None, + ) + + async def run_stateless_server(*, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED): + async with http_transport.connect() as streams: + read_stream, write_stream = streams + task_status.started() + try: + await mcp_utility.server.run( + read_stream, + write_stream, + mcp_utility.server.create_initialization_options(), + stateless=True, + ) + except Exception: + logger.exception("Legacy stateless MCP session crashed") + + async with anyio.create_task_group() as tg: + await tg.start(run_stateless_server) + await http_transport.handle_request(scope, request.receive, request.send) + await http_transport.terminate() + + return _make_dummy_response() finally: clear_mcp_context() - # Response already sent via request.send(); return dummy so framework does not send again. - resp = Response() - resp._prepared = True - resp._eof_sent = True - return resp @configure.service( diff --git a/guillotina/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py index 13ad4afcf..6cedb20ca 100644 --- a/guillotina/contrib/mcp/tools.py +++ b/guillotina/contrib/mcp/tools.py @@ -1,7 +1,11 @@ +from guillotina import configure from guillotina._settings import app_settings +from guillotina.component import query_utility from guillotina.contrib.mcp.backend import get_mcp_context from guillotina.contrib.mcp.backend import InProcessBackend from guillotina.contrib.mcp.interfaces import IMCPDescriptionExtras +from guillotina.contrib.mcp.interfaces import IMCPToolProvider +from zope.interface import implementer import json import typing @@ -82,8 +86,6 @@ def _normalize_query( def _get_description_extras(): - from guillotina.component import query_utility - extras = dict(app_settings.get("mcp", {}).get("description_extras") or {}) util = query_utility(IMCPDescriptionExtras) if util is not None: @@ -92,85 +94,126 @@ def _get_description_extras(): return extras -def register_tools(mcp_server, backend: InProcessBackend): - async def _context_for_path(container_path: typing.Optional[str]): - ctx = get_mcp_context() - if ctx is None: +def _tool_description(name: str) -> str: + extras = _get_description_extras() + return (TOOL_DESCRIPTIONS[name] + " " + (extras.get(name) or "")).strip() + + +def _tool_input_schema(name: str) -> typing.Dict[str, typing.Any]: + return {"type": "object", **CHAT_PARAM_SCHEMAS[name]} + + +def _tool_definition(name: str) -> typing.Dict[str, typing.Any]: + return { + "name": name, + "description": _tool_description(name), + "input_schema": _tool_input_schema(name), + } + + +async def _context_for_path(container_path: typing.Optional[str]): + ctx = get_mcp_context() + if ctx is None: + return None + if container_path: + from guillotina.utils import navigate_to + + try: + return await navigate_to(ctx, "/" + container_path.strip("/")) + except KeyError: return None - if container_path: - from guillotina.utils import navigate_to + return ctx - try: - return await navigate_to(ctx, "/" + container_path.strip("/")) - except KeyError: - return None - return ctx - extras = _get_description_extras() +@implementer(IMCPToolProvider) +@configure.utility(provides=IMCPToolProvider, name="search") +class SearchToolProvider: + def __init__(self): + self.backend = InProcessBackend() + + def get_tool_definition(self) -> typing.Dict[str, typing.Any]: + return _tool_definition("search") - @mcp_server.tool(description=(TOOL_DESCRIPTIONS["search"] + " " + (extras.get("search") or "")).strip()) - async def search( - container_path: typing.Optional[str] = None, - query: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> dict: - context = await _context_for_path(container_path) + async def execute(self, arguments: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + args = arguments if isinstance(arguments, dict) else {} + context = await _context_for_path(args.get("container_path")) if context is None: return {"items": [], "items_total": 0} - q = _normalize_query(query) - return await backend.search(context, q) - - @mcp_server.tool(description=(TOOL_DESCRIPTIONS["count"] + " " + (extras.get("count") or "")).strip()) - async def count( - container_path: typing.Optional[str] = None, - query: typing.Optional[typing.Dict[str, typing.Any]] = None, - ): - context = await _context_for_path(container_path) + query = _normalize_query(args.get("query")) + return await self.backend.search(context, query) + + +@implementer(IMCPToolProvider) +@configure.utility(provides=IMCPToolProvider, name="count") +class CountToolProvider: + def __init__(self): + self.backend = InProcessBackend() + + def get_tool_definition(self) -> typing.Dict[str, typing.Any]: + return _tool_definition("count") + + async def execute(self, arguments: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + args = arguments if isinstance(arguments, dict) else {} + context = await _context_for_path(args.get("container_path")) if context is None: - return 0 - q = _normalize_query(query) - return await backend.count(context, q) - - @mcp_server.tool( - description=(TOOL_DESCRIPTIONS["get_content"] + " " + (extras.get("get_content") or "")).strip() - ) - async def get_content( - path: typing.Optional[str] = None, - uid: typing.Optional[str] = None, - container_path: typing.Optional[str] = None, - ) -> dict: - context = await _context_for_path(container_path) + return {"count": 0} + query = _normalize_query(args.get("query")) + value = await self.backend.count(context, query) + return {"count": value} + + +@implementer(IMCPToolProvider) +@configure.utility(provides=IMCPToolProvider, name="get_content") +class GetContentToolProvider: + def __init__(self): + self.backend = InProcessBackend() + + def get_tool_definition(self) -> typing.Dict[str, typing.Any]: + return _tool_definition("get_content") + + async def execute(self, arguments: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + args = arguments if isinstance(arguments, dict) else {} + context = await _context_for_path(args.get("container_path")) if context is None: return {} - return await backend.get_content(context, path, uid) - - @mcp_server.tool( - description=(TOOL_DESCRIPTIONS["list_children"] + " " + (extras.get("list_children") or "")).strip() - ) - async def list_children( - path: str = "", - from_index: int = 0, - page_size: int = 20, - container_path: typing.Optional[str] = None, - ) -> dict: - context = await _context_for_path(container_path) + return await self.backend.get_content(context, args.get("path"), args.get("uid")) + + +@implementer(IMCPToolProvider) +@configure.utility(provides=IMCPToolProvider, name="list_children") +class ListChildrenToolProvider: + def __init__(self): + self.backend = InProcessBackend() + + def get_tool_definition(self) -> typing.Dict[str, typing.Any]: + return _tool_definition("list_children") + + async def execute(self, arguments: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + args = arguments if isinstance(arguments, dict) else {} + context = await _context_for_path(args.get("container_path")) if context is None: return {"items": [], "items_total": 0} - return await backend.list_children(context, path or "", from_index, page_size) + try: + from_index = int(args.get("from_index", 0)) + except (TypeError, ValueError): + from_index = 0 + try: + page_size = int(args.get("page_size", 20)) + except (TypeError, ValueError): + page_size = 20 + return await self.backend.list_children(context, args.get("path") or "", from_index, page_size) def get_chat_tools(): """Return built-in tool definitions in the format expected by LiteLLM for @chat (all providers).""" - extras = _get_description_extras() - descriptions = { - name: (TOOL_DESCRIPTIONS[name] + " " + (extras.get(name) or "")).strip() for name in TOOL_DESCRIPTIONS - } + descriptions = {name: _tool_description(name) for name in TOOL_DESCRIPTIONS} return [ { "type": "function", "function": { "name": name, "description": descriptions[name], - "parameters": {"type": "object", **CHAT_PARAM_SCHEMAS[name]}, + "parameters": _tool_input_schema(name), }, } for name in TOOL_DESCRIPTIONS diff --git a/guillotina/contrib/mcp/utility.py b/guillotina/contrib/mcp/utility.py index 4f48028d1..2e057bc8c 100644 --- a/guillotina/contrib/mcp/utility.py +++ b/guillotina/contrib/mcp/utility.py @@ -1,9 +1,12 @@ -from guillotina._settings import app_settings -from guillotina.contrib.mcp.backend import InProcessBackend +from guillotina.component import get_utilities_for +from guillotina.component import query_utility +from guillotina.contrib.mcp.interfaces import IMCPToolProvider from guillotina.contrib.mcp.interfaces import IMCPUtility -from guillotina.contrib.mcp.tools import register_tools from zope.interface import implementer +import json +import typing + @implementer(IMCPUtility) class MCPUtility: @@ -14,29 +17,51 @@ async def finalize(self, app): pass def __init__(self, settings=None): - from mcp.server.fastmcp import FastMCP + from mcp.server.lowlevel.server import Server + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + import mcp.types as types - settings = settings or {} - backend = InProcessBackend() - mcp = FastMCP( + server = Server( "Guillotina MCP", + ) + + @server.list_tools() + async def _list_tools(): + tools = [] + for name, provider in sorted(get_utilities_for(IMCPToolProvider), key=lambda value: value[0]): + definition = provider.get_tool_definition() or {} + tool_name = definition.get("name") or name + tools.append( + types.Tool( + name=tool_name, + description=definition.get("description", ""), + inputSchema=definition.get("input_schema") or {"type": "object", "properties": {}}, + ) + ) + return tools + + @server.call_tool() + async def _call_tool(name: str, arguments: typing.Optional[typing.Dict[str, typing.Any]]): + provider = query_utility(IMCPToolProvider, name=name) + if provider is None: + raise ValueError(f"Unknown tool: {name}") + result = await provider.execute(arguments or {}) + if not isinstance(result, dict): + raise TypeError(f"Invalid result for {name}: {type(result).__name__}") + text = json.dumps(result, indent=2, ensure_ascii=False) + return [types.TextContent(type="text", text=text)] + + self._server = server + self._session_manager = StreamableHTTPSessionManager( + app=server, json_response=True, - stateless_http=True, + stateless=True, ) - if hasattr(mcp, "settings") and hasattr(mcp.settings, "streamable_http_path"): - mcp.settings.streamable_http_path = "/" - register_tools(mcp, backend) - extra_module = app_settings.get("mcp", {}).get("extra_tools_module") - if extra_module: - mod = __import__(str(extra_module), fromlist=["register_extra_tools"]) - getattr(mod, "register_extra_tools")(mcp, backend) - self._server = mcp - self._app = mcp.streamable_http_app() @property def server(self): return self._server @property - def app(self): - return self._app + def session_manager(self): + return self._session_manager