forked from Rockachopa/Timmy-time-dashboard
fix: use StdioServerParameters to bypass Agno executable whitelist
Agno's MCPTools has an undocumented executable whitelist that blocks gitea-mcp (Go binary). Switch to server_params=StdioServerParameters() which bypasses this restriction. Also fixes: - Use tools.session.call_tool() for standalone invocation (MCPTools doesn't expose call_tool() directly) - Use close() instead of disconnect() for cleanup - Resolve gitea-mcp path via ~/go/bin fallback when not on PATH - Stub mcp.client.stdio in test conftest Smoke-tested end-to-end against real Gitea: connect, list_issues, create issue, close issue, create_gitea_issue_via_mcp — all pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -34,11 +36,55 @@ logger = logging.getLogger(__name__)
|
||||
_issue_session = None
|
||||
|
||||
|
||||
def _parse_command(command_str: str) -> tuple[str, list[str]]:
|
||||
"""Split a command string into (executable, args).
|
||||
|
||||
Handles ``~/`` expansion and resolves via PATH if needed.
|
||||
E.g. ``"gitea-mcp -t stdio"`` → ``("/Users/x/go/bin/gitea-mcp", ["-t", "stdio"])``
|
||||
"""
|
||||
parts = command_str.split()
|
||||
executable = os.path.expanduser(parts[0])
|
||||
|
||||
# If not an absolute path, resolve via shutil.which
|
||||
if not os.path.isabs(executable):
|
||||
resolved = shutil.which(executable)
|
||||
if resolved:
|
||||
executable = resolved
|
||||
else:
|
||||
# Check common binary locations not always on PATH
|
||||
for candidate_dir in ["~/go/bin", "~/.local/bin", "~/bin"]:
|
||||
candidate = os.path.expanduser(os.path.join(candidate_dir, parts[0]))
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
executable = candidate
|
||||
break
|
||||
|
||||
return executable, parts[1:]
|
||||
|
||||
|
||||
def _gitea_server_params():
|
||||
"""Build ``StdioServerParameters`` for the Gitea MCP server."""
|
||||
from mcp.client.stdio import StdioServerParameters
|
||||
|
||||
exe, args = _parse_command(settings.mcp_gitea_command)
|
||||
return StdioServerParameters(
|
||||
command=exe,
|
||||
args=args,
|
||||
env={
|
||||
"GITEA_ACCESS_TOKEN": settings.gitea_token,
|
||||
"GITEA_HOST": settings.gitea_url,
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_gitea_mcp_tools():
|
||||
"""Create an MCPTools instance for the Gitea MCP server.
|
||||
|
||||
Returns None if Gitea is disabled or not configured (no token).
|
||||
The returned MCPTools is lazy — Agno connects it on first ``arun()``.
|
||||
|
||||
Uses ``server_params`` instead of ``command`` to bypass Agno's
|
||||
executable whitelist (gitea-mcp is a Go binary not in the list).
|
||||
"""
|
||||
if not settings.gitea_enabled or not settings.gitea_token:
|
||||
logger.debug("Gitea MCP: disabled or no token configured")
|
||||
@@ -47,15 +93,8 @@ def create_gitea_mcp_tools():
|
||||
try:
|
||||
from agno.tools.mcp import MCPTools
|
||||
|
||||
# Build command — gitea-mcp expects "-t stdio" for stdio transport
|
||||
command = settings.mcp_gitea_command
|
||||
|
||||
tools = MCPTools(
|
||||
command=command,
|
||||
env={
|
||||
"GITEA_ACCESS_TOKEN": settings.gitea_token,
|
||||
"GITEA_HOST": settings.gitea_url,
|
||||
},
|
||||
server_params=_gitea_server_params(),
|
||||
include_tools=[
|
||||
"issue_write",
|
||||
"issue_read",
|
||||
@@ -80,14 +119,28 @@ def create_filesystem_mcp_tools():
|
||||
|
||||
Returns None if the command is not configured.
|
||||
Scoped to the project repo_root directory.
|
||||
|
||||
Uses ``server_params`` for consistency (npx is whitelisted by Agno
|
||||
but server_params is the more robust approach).
|
||||
"""
|
||||
try:
|
||||
from agno.tools.mcp import MCPTools
|
||||
from mcp.client.stdio import StdioServerParameters
|
||||
|
||||
command = f"{settings.mcp_filesystem_command} {settings.repo_root}"
|
||||
# Parse the base command, then append repo_root as an extra arg
|
||||
exe, args = _parse_command(settings.mcp_filesystem_command)
|
||||
args.append(str(settings.repo_root))
|
||||
|
||||
params = StdioServerParameters(
|
||||
command=exe,
|
||||
args=args,
|
||||
env={
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
},
|
||||
)
|
||||
|
||||
tools = MCPTools(
|
||||
command=command,
|
||||
server_params=params,
|
||||
include_tools=[
|
||||
"read_file",
|
||||
"write_file",
|
||||
@@ -151,6 +204,9 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
||||
Used by the thinking engine's ``_maybe_file_issues()`` post-hook.
|
||||
Manages its own MCPTools session with lazy connect + graceful failure.
|
||||
|
||||
Uses ``tools.session.call_tool()`` for direct MCP invocation — the
|
||||
``MCPTools`` wrapper itself does not expose ``call_tool()``.
|
||||
|
||||
Args:
|
||||
title: Issue title.
|
||||
body: Issue body (markdown).
|
||||
@@ -169,11 +225,7 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
||||
|
||||
if _issue_session is None:
|
||||
_issue_session = MCPTools(
|
||||
command=settings.mcp_gitea_command,
|
||||
env={
|
||||
"GITEA_ACCESS_TOKEN": settings.gitea_token,
|
||||
"GITEA_HOST": settings.gitea_url,
|
||||
},
|
||||
server_params=_gitea_server_params(),
|
||||
timeout_seconds=settings.mcp_timeout,
|
||||
)
|
||||
|
||||
@@ -200,8 +252,8 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
|
||||
"body": full_body,
|
||||
}
|
||||
|
||||
# Call the MCP tool directly via the session
|
||||
result = await _issue_session.call_tool("issue_write", arguments=args)
|
||||
# Call via the underlying MCP session (MCPTools doesn't expose call_tool)
|
||||
result = await _issue_session.session.call_tool("issue_write", arguments=args)
|
||||
|
||||
# Bridge to local work order
|
||||
label_list = [tag.strip() for tag in labels.split(",") if tag.strip()] if labels else []
|
||||
@@ -221,7 +273,7 @@ async def close_mcp_sessions() -> None:
|
||||
global _issue_session
|
||||
if _issue_session is not None:
|
||||
try:
|
||||
await _issue_session.disconnect()
|
||||
await _issue_session.close()
|
||||
except Exception as exc:
|
||||
logger.debug("MCP session disconnect error: %s", exc)
|
||||
logger.debug("MCP session close error: %s", exc)
|
||||
_issue_session = None
|
||||
|
||||
Reference in New Issue
Block a user