diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 8b4950b0e..8f2c31059 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -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/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/Makefile b/Makefile index 4211fd047..be7f06cc1 100644 --- a/Makefile +++ b/Makefile @@ -41,3 +41,13 @@ 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/ + +.PHONY: tests +tests: ## Run tests + DATABASE=POSTGRES pytest -s -x guillotina \ No newline at end of file diff --git a/contrib-requirements.txt b/contrib-requirements.txt index b7260ebc9..68eb8ab07 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' 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..6d06403b5 --- /dev/null +++ b/docs/source/contrib/mcp.md @@ -0,0 +1,196 @@ +# 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 + +**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. Install the MCP extra (Python 3.10+ only): + + ```bash + pip install guillotina[mcp] + ``` + + 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: + + ```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`, `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 `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) + +- **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/__init__.py b/guillotina/contrib/mcp/__init__.py new file mode 100644 index 000000000..3efd96902 --- /dev/null +++ b/guillotina/contrib/mcp/__init__.py @@ -0,0 +1,29 @@ +from guillotina import configure + + +app_settings = { + "mcp": { + "enabled": True, + "description_extras": {}, + "extra_tools_module": None, + "token_max_duration_days": 90, + "token_allowed_durations": None, + "chat_enabled": True, + "chat_model": None, + }, + "load_utilities": { + "guillotina.mcp": { + "provides": "guillotina.contrib.mcp.interfaces.IMCPUtility", + "factory": "guillotina.contrib.mcp.utility.MCPUtility", + "settings": {}, + } + }, +} + + +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/backend.py b/guillotina/contrib/mcp/backend.py new file mode 100644 index 000000000..1160f5b3f --- /dev/null +++ b/guillotina/contrib/mcp/backend.py @@ -0,0 +1,133 @@ +from contextvars import ContextVar +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 get_security_policy +from guillotina.utils import navigate_to +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) + + +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 # No active context set; clear operation is intentionally idempotent. + + +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 + + def _resolve_context(self, context: 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 = 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 = self._resolve_context(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: Optional[str], + uid: Optional[str], + ) -> dict: + from guillotina import task_vars + + base = self._resolve_context(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 {} + if not get_security_policy().check_permission("guillotina.ViewContent", ob): + 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 = self._resolve_context(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} + if not get_security_policy().check_permission("guillotina.ViewContent", container): + return {"items": [], "items_total": 0} + request = task_vars.request.get() + policy = get_security_policy() + items: List[Dict[str, Any]] = [] + 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): + 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 new file mode 100644 index 000000000..18928a140 --- /dev/null +++ b/guillotina/contrib/mcp/chat.py @@ -0,0 +1,208 @@ +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 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 +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_litellm_credentials(model: str): + api_key = "" + api_base = None + if model.startswith("openai/"): + 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 + + +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": + if context is None: + return {"items": [], "items_total": 0} + return await backend.search(context, _normalize_query(args.get("query"))) + if name == "count": + if context is None: + return 0 + return await backend.count(context, _normalize_query(args.get("query"))) + if name == "get_content": + 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( + context, + args.get("path") or "", + 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}"} + + +@configure.service( + context=IResource, + method="POST", + permission="guillotina.mcp.Use", + name="@chat", + summary="Chat with LLM using MCP tools (OpenAI, Gemini, Anthropic, Groq, OpenRouter, MiniMax, Mistral, Deepseek, Cerebras)", # noqa: E501 +) +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_all_chat_tools() + 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) + 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 = _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 = _get_value(msg, "content", None) or "" + return {"content": content} + assistant_msg = {"role": "assistant", "content": _get_value(msg, "content", None)} + tool_calls_list = [] + for tc in tool_calls: + 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, + "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 = _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: + 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/interfaces.py b/guillotina/contrib/mcp/interfaces.py new file mode 100644 index 000000000..c4aeb1eb7 --- /dev/null +++ b/guillotina/contrib/mcp/interfaces.py @@ -0,0 +1,26 @@ +from zope.interface import Attribute +from zope.interface import Interface + + +class IMCPUtility(Interface): + """MCP utility providing low-level server and session manager.""" + + 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): + """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/lifespan.py b/guillotina/contrib/mcp/lifespan.py new file mode 100644 index 000000000..855768f74 --- /dev/null +++ b/guillotina/contrib/mcp/lifespan.py @@ -0,0 +1,48 @@ +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 + + +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.session_manager + 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): + stop.set() + await manager_task + 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/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/permissions.py b/guillotina/contrib/mcp/permissions.py new file mode 100644 index 000000000..2a67e9dea --- /dev/null +++ b/guillotina/contrib/mcp/permissions.py @@ -0,0 +1,7 @@ +from guillotina import configure + + +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 new file mode 100644 index 000000000..f470168da --- /dev/null +++ b/guillotina/contrib/mcp/services.py @@ -0,0 +1,203 @@ +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.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.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 +from multidict import CIMultiDict + +import anyio +import copy +import json +import logging + + +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, captured response)", +) +@configure.service( + context=IResource, + method="GET", + permission="guillotina.mcp.Use", + name="@mcp", + summary="MCP protocol endpoint (GET, captured response)", +) +async def mcp_service(context, request): + _ensure_mcp_enabled() + set_mcp_context(context) + try: + scope = copy.copy(request.scope) + scope["path"] = "/" + scope["raw_path"] = b"/" + mcp_utility = get_utility(IMCPUtility) + + 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) + + 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() + + +@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 (json.JSONDecodeError, ValueError): + 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/contrib/mcp/tools.py b/guillotina/contrib/mcp/tools.py new file mode 100644 index 000000000..6cedb20ca --- /dev/null +++ b/guillotina/contrib/mcp/tools.py @@ -0,0 +1,239 @@ +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 + + +TOOL_DESCRIPTIONS = { + "search": ( + "Search the catalog. container_path is optional (default: current context). " + "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 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. " + "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." + ), +} + +CHAT_PARAM_SCHEMAS = { + "search": { + "properties": { + "container_path": {"type": "string", "description": "Optional path relative to container."}, + "query": { + "type": "object", + "description": "Search query object. Keys: type_name, _size, fieldname__gte, fieldname__lte (dates ISO 8601).", # noqa: E501 + }, + }, + }, + "count": { + "properties": { + "container_path": {"type": "string", "description": "Optional path relative to container."}, + "query": { + "type": "object", + "description": "Count query object. Keys: type_name, fieldname__gte, fieldname__lte (dates ISO 8601).", + }, + }, + }, + "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 _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(): + 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 _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 + return ctx + + +@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") + + 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} + 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 {"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 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} + 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).""" + descriptions = {name: _tool_description(name) for name in TOOL_DESCRIPTIONS} + return [ + { + "type": "function", + "function": { + "name": name, + "description": descriptions[name], + "parameters": _tool_input_schema(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 diff --git a/guillotina/contrib/mcp/utility.py b/guillotina/contrib/mcp/utility.py new file mode 100644 index 000000000..2e057bc8c --- /dev/null +++ b/guillotina/contrib/mcp/utility.py @@ -0,0 +1,67 @@ +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 zope.interface import implementer + +import json +import typing + + +@implementer(IMCPUtility) +class MCPUtility: + async def initialize(self, app): + pass + + async def finalize(self, app): + pass + + def __init__(self, settings=None): + from mcp.server.lowlevel.server import Server + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + import mcp.types as types + + 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=True, + ) + + @property + def server(self): + return self._server + + @property + def session_manager(self): + return self._session_manager 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/guillotina/tests/test_mcp.py b/guillotina/tests/test_mcp.py new file mode 100644 index 000000000..b0f16917c --- /dev/null +++ b/guillotina/tests/test_mcp.py @@ -0,0 +1,402 @@ +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.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 +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: + mcp = None + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(mcp is None, reason="mcp package requires Python 3.10+"), +] + + +@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() + + +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: + resp, status = await requester("GET", "/db/guillotina/@mcp") + assert status == 421 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"], "mcp": {"enabled": False}}) +async def test_mcp_disabled_returns_404(container_requester): + async with container_requester as requester: + _, status = await requester("GET", "/db/guillotina/@mcp") + assert status == 404 + + +@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( + "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 == 421 + + +@pytest.mark.app_settings({"applications": ["guillotina.contrib.mcp"]}) +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"]}) +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 + + +@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"] + + +@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_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: + _, 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 diff --git a/requirements.txt b/requirements.txt index 41d0ff7f9..ea5d4e90f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,28 @@ Cython==0.29.24 asyncpg>=0.28.0 -cffi==1.14.6 +cffi>=1.17.1,<2.0; python_version < '3.9' +cffi>=2.0.0; python_version >= '3.9' chardet==3.0.4 -jsonschema==2.6.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 -PyJWT~=2.1.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 orjson>=3,<4 zope.interface==5.1.0 -uvicorn==0.17.6 -argon2-cffi==18.3.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==3.7.4.3 +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 @@ -26,4 +32,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..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==2.6.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={ @@ -111,6 +117,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": [