Skip to content

MCP#1209

Open
rboixaderg wants to merge 24 commits intomasterfrom
query_ai
Open

MCP#1209
rboixaderg wants to merge 24 commits intomasterfrom
query_ai

Conversation

@rboixaderg
Copy link
Contributor

No description provided.

- 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.
…andling

- 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.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds MCP (Model Context Protocol) support to Guillotina as a new contrib module. MCP enables AI assistants and LLMs to interact with Guillotina content through a standardized protocol. The implementation provides both in-process and HTTP-based backends for querying and retrieving content.

Changes:

  • Added a new guillotina.contrib.mcp module with services, backends, tools, and command support for MCP protocol integration
  • Implemented in-process and HTTP backends for search, count, get_content, and list_children operations
  • Added authentication and token management services for MCP clients
  • Included basic test coverage for the MCP functionality

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 21 comments.

Show a summary per file
File Description
guillotina/tests/test_mcp.py Adds basic tests for MCP service registration, backend context requirements, authentication, and token generation
guillotina/contrib/mcp/tools.py Defines MCP tool functions (search, count, get_content, list_children) and registers them with the MCP server
guillotina/contrib/mcp/services.py Implements the @mcp endpoint service and @mcp-token service for JWT token generation
guillotina/contrib/mcp/server.py Provides MCP server initialization and ASGI app creation with global state management
guillotina/contrib/mcp/permissions.py Defines two new permissions for MCP usage and token issuance, granted to authenticated users
guillotina/contrib/mcp/interfaces.py Defines IMCPDescriptionExtras interface for extending tool descriptions
guillotina/contrib/mcp/command.py Implements CLI command for running standalone MCP server with HTTP backend
guillotina/contrib/mcp/backend.py Implements InProcessBackend and HttpBackend classes for content operations with context management
guillotina/contrib/mcp/init.py Module initialization with default settings and configuration scanning
Makefile Adds format and tests targets for code formatting and test execution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@rboixaderg rboixaderg requested a review from Copilot January 30, 2026 16:17
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +10 to +31
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()
return _mcp_app, _mcp_server
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The InProcessBackend is created once when the MCP server is first initialized and then reused for all subsequent requests. However, the backend relies on the MCP context which changes per request via a ContextVar. While ContextVars are thread-safe and async-safe, this design should be verified to work correctly when multiple requests are handled concurrently. Consider adding a comment explaining this design decision or restructuring to create the backend per-request if needed.

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When extra_tools_module is configured, the code assumes the register_extra_tools function exists but doesn't handle AttributeError if it's missing. This will cause the server initialization to fail with an unclear error. Add error handling to validate that the function exists and provide a helpful error message if it's missing.

Suggested change
getattr(mod, "register_extra_tools")(mcp, backend)
extra_tools_registrar = getattr(mod, "register_extra_tools", None)
if not callable(extra_tools_registrar):
raise RuntimeError(
f"Configured extra_tools_module '{extra_module}' does not define a callable "
"'register_extra_tools(mcp, backend)' function."
)
extra_tools_registrar(mcp, backend)

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +56
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
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code accesses a private attribute _task_group on the session_manager. While there's a comment explaining this is a workaround, accessing private attributes is fragile and could break if the mcp library changes its internal implementation. Consider documenting which version of the mcp library this workaround is for and adding error handling in case the attribute doesn't exist in future versions.

Suggested change
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
# Workaround: current mcp library version does not expose the session task group via
# a public API, so we temporarily override the private `_task_group` attribute.
# This is intentionally defensive: if a future mcp version removes or renames
# `_task_group`, we fall back to calling the app without overriding it.
original_task_group = getattr(session_manager, "_task_group", None)
if original_task_group is not None:
async with anyio.create_task_group() as tg:
session_manager._task_group = tg
try:
await app(scope, request.receive, request.send)
finally:
# Restore original task group to avoid leaving session_manager mutated.
session_manager._task_group = original_task_group
else:
logger.warning(
"MCP session_manager has no '_task_group' attribute; "
"running MCP app without overriding task group. "
"This may indicate an incompatible mcp library version."
)
await app(scope, request.receive, request.send)

Copilot uses AI. Check for mistakes.
Comment on lines 141 to 179
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)})
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is significant code duplication in parsing tool calls. Lines 141-156 and 159-169 contain nearly identical logic for extracting tc_id, function name, and arguments. This makes the code harder to maintain. Consider extracting this logic into a helper function to reduce duplication and improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +120
litellm = __import__("litellm", fromlist=["acompletion"])
acompletion = getattr(litellm, "acompletion")
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat service dynamically imports litellm without verifying it's installed first. While pytest.importorskip is used in tests, the actual service code should handle ImportError gracefully and return a clear error message if litellm is not available. This would provide better user experience when litellm is not installed but chat is enabled.

Suggested change
litellm = __import__("litellm", fromlist=["acompletion"])
acompletion = getattr(litellm, "acompletion")
try:
litellm = __import__("litellm")
except ImportError:
raise HTTPPreconditionFailed(
content={
"reason": "litellm is not installed",
"hint": "Install the 'litellm' package to use the @chat endpoint.",
}
)
acompletion = getattr(litellm, "acompletion", None)
if acompletion is None or not callable(acompletion):
raise HTTPPreconditionFailed(
content={
"reason": "litellm.acompletion is unavailable",
"hint": "Ensure a compatible version of 'litellm' providing 'acompletion' is installed.",
}
)

Copilot uses AI. Check for mistakes.
Comment on lines 80 to 91
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 navigate_to(ctx, "/" + container_path.strip("/"))
except KeyError:
return None
return ctx
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The navigate_to function is async but is being called without await. This will return a coroutine object instead of the actual result, causing the function to return None or raise an error when the coroutine is used. This function should be made async and the navigate_to call should be awaited.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant