-
-
Notifications
You must be signed in to change notification settings - Fork 52
MCP #1209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rboixaderg
wants to merge
36
commits into
master
Choose a base branch
from
query_ai
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
MCP #1209
Changes from 9 commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
e98d46a
wip: query_ai
rboixaderg dade433
feat: Enhance AI query handling with logging and multi-step support
rboixaderg 3f6dda7
feat: Implement retry mechanism for empty query results in AI query h…
rboixaderg a30945b
feat: Introduce MCP integration
rboixaderg 3052880
feat: Add token issuance for MCP with configurable duration
rboixaderg 0524444
feat: Remove AI query module and related components
rboixaderg bd98a75
feat: Add code formatting and testing targets to Makefile
rboixaderg 1cfb2f6
feat: Refactor MCP integration by removing unused components and enha…
rboixaderg abd32a3
feat: Remove MCP README documentation as part of project cleanup
rboixaderg b18ee39
feat: Add chat functionality to MCP with LLM integration
rboixaderg 3d4c7c4
feat: Introduce MCP tools and chat endpoint with enhanced configuration
rboixaderg 8ba8cf2
feat: Add MCP contrib and update requirements
rboixaderg ae4938c
chore: copilot suggestions
rboixaderg f8701c0
fix: Update MCP requirements and documentation for Python 3.10+ compa…
rboixaderg 85f4f44
chore: Update Makefile to add .PHONY declaration for tests target
rboixaderg 83abee0
chore: Update jinja2 and MarkupSafe versions in contrib-requirements.…
rboixaderg a98eb19
feat: Enhance MCP chat model support and update documentation
rboixaderg 6031e7d
feat: Implement security checks in InProcessBackend for content visib…
rboixaderg dabd2fa
feat: Refactor MCP backend and chat tool execution for improved conte…
rboixaderg 0ce4b9d
chore: Update dependencies in requirements.txt and setup.py for impro…
rboixaderg bf84b96
refactor: Simplify type annotations in MCP backend for improved reada…
rboixaderg 1ee5075
refactor: Organize type imports in MCP backend for clarity
rboixaderg 779fec4
chore: Update CI workflow to support Python versions 3.10, 3.11, 3.12…
rboixaderg cc84e39
chore: Update CI workflow to limit supported Python versions to 3.10 …
rboixaderg e498d4f
refactor: Update register_tools function to use InProcessBackend type…
rboixaderg 030b9a5
test: Update MCP service tests to assert correct status codes for ena…
rboixaderg 5261e38
adong locking
nilbacardit26 50304ff
feat: Introduce MCPUtility for managing FastMCP server and app instan…
rboixaderg 8ba5d30
chore: Update contrib-requirements and setup.py to include MCP depend…
rboixaderg bb43682
chore: Update requirements.txt to specify version ranges for cffi and…
rboixaderg 8b306b0
chore: Update jsonschema version specifications in requirements.txt t…
rboixaderg 5d37add
chore: Adjust PyJWT version specifications in requirements.txt for im…
rboixaderg e76c321
chore: Refine version specifications for uvicorn, jsonschema, cffi, a…
rboixaderg d8d4e2e
feat: Add lifespan management for MCP utility and normalize query han…
rboixaderg 1d99968
refactor: Enhance MCP lifespan management by implementing asynchronou…
rboixaderg 10a6540
wip: Mcp
rboixaderg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| from guillotina import configure | ||
|
|
||
|
|
||
| app_settings = { | ||
| "mcp": { | ||
| "enabled": True, | ||
| "description_extras": {}, | ||
| "extra_tools_module": None, | ||
| "token_max_duration_days": 90, | ||
| "token_allowed_durations": None, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def includeme(root, settings): | ||
| configure.scan("guillotina.contrib.mcp.permissions") | ||
| configure.scan("guillotina.contrib.mcp.services") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| 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 navigate_to | ||
|
|
||
| import typing | ||
|
|
||
|
|
||
| _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: | ||
rboixaderg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
|
|
||
| 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 = 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: typing.Optional[str], | ||
| uid: typing.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 {} | ||
| 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} | ||
| 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} | ||
rboixaderg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| from zope.interface import Interface | ||
|
|
||
|
|
||
| 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. | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| from guillotina._settings import app_settings | ||
| from guillotina.contrib.mcp.backend import InProcessBackend | ||
| from guillotina.contrib.mcp.tools import register_tools | ||
|
|
||
|
|
||
| _mcp_server = None | ||
| _mcp_app = 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 | ||
|
|
||
| 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) | ||
rboixaderg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| _mcp_server = mcp | ||
| _mcp_app = mcp.streamable_http_app() | ||
| return _mcp_app, _mcp_server | ||
rboixaderg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| 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_app_and_server | ||
| 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 | ||
rboixaderg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import copy | ||
| import logging | ||
|
|
||
|
|
||
| logger = logging.getLogger("guillotina") | ||
|
|
||
|
|
||
| @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) | ||
rboixaderg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| 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 | ||
rboixaderg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| finally: | ||
| clear_mcp_context() | ||
| resp = Response() | ||
| resp._prepared = True | ||
| resp._eof_sent = True | ||
| return resp | ||
rboixaderg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| @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 = {} | ||
rboixaderg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
rboixaderg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| jwt_token, data = authenticate_user(user.id, data={"purpose": "mcp"}, timeout=timeout) | ||
| return { | ||
| "token": jwt_token, | ||
| "exp": data["exp"], | ||
| "duration_days": duration_days, | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.