This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/timmy/mcp_tools.py
Kimi Agent a57fd7ea09 [loop-cycle-30] fix: gitea-mcp binary name + test stabilization
1. gitea-mcp → gitea-mcp-server (brew binary name). Fixes Timmy's
   Gitea triage — MCP server can now be found on PATH.
2. Mark test_returns_dict_with_expected_keys as @pytest.mark.slow —
   it runs pytest recursively and always exceeds the 30s timeout.
3. Fix ruff F841 lint in test_cli.py (unused result= variable).
2026-03-14 21:32:39 -04:00

280 lines
9.2 KiB
Python

"""MCP tool server factories for Agno agent integration.
Provides factory functions that create ``MCPTools`` instances for external
tool servers (Gitea, Filesystem) using stdio transport. Also provides a
standalone async helper for filing Gitea issues from the thinking engine
without going through the full LLM loop.
Usage::
from timmy.mcp_tools import create_gitea_mcp_tools, create_filesystem_mcp_tools
# In agent creation (added to tools list):
gitea_tools = create_gitea_mcp_tools()
fs_tools = create_filesystem_mcp_tools()
# Direct issue filing (thinking engine):
from timmy.mcp_tools import create_gitea_issue_via_mcp
result = await create_gitea_issue_via_mcp("Bug title", "Body", "bug")
"""
from __future__ import annotations
import logging
import os
import shutil
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
# Module-level cache for the standalone issue-filing session
_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-server -t stdio"`` → ``("/opt/homebrew/bin/gitea-mcp-server", ["-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")
return None
try:
from agno.tools.mcp import MCPTools
tools = MCPTools(
server_params=_gitea_server_params(),
include_tools=[
"issue_write",
"issue_read",
"list_issues",
"pull_request_write",
"pull_request_read",
"list_pull_requests",
"list_branches",
"list_commits",
],
timeout_seconds=settings.mcp_timeout,
)
logger.info("Gitea MCP tools created (lazy connect)")
return tools
except Exception as exc:
logger.warning("Failed to create Gitea MCP tools: %s", exc)
return None
def create_filesystem_mcp_tools():
"""Create an MCPTools instance for the filesystem MCP server.
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
# 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(
server_params=params,
include_tools=[
"read_file",
"write_file",
"list_directory",
"search_files",
"get_file_info",
"directory_tree",
],
timeout_seconds=settings.mcp_timeout,
)
logger.info("Filesystem MCP tools created (lazy connect)")
return tools
except Exception as exc:
logger.warning("Failed to create filesystem MCP tools: %s", exc)
return None
def _bridge_to_work_order(title: str, body: str, category: str) -> None:
"""Create a local work order so the dashboard tracks the issue."""
try:
db_path = Path(settings.repo_root) / "data" / "work_orders.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute(
"""CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'suggestion',
submitter TEXT DEFAULT 'dashboard',
related_files TEXT DEFAULT '',
status TEXT DEFAULT 'submitted',
result TEXT DEFAULT '',
rejection_reason TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)"""
)
conn.execute(
"INSERT INTO work_orders (id, title, description, category, submitter, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
title,
body,
category,
"timmy-thinking",
datetime.utcnow().isoformat(),
),
)
conn.commit()
conn.close()
except Exception as exc:
logger.debug("Work order bridge failed: %s", exc)
async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "") -> str:
"""File a Gitea issue via the MCP server (standalone, no LLM loop).
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).
labels: Comma-separated label names.
Returns:
Confirmation string or error explanation.
"""
if not settings.gitea_enabled or not settings.gitea_token:
return "Gitea integration is not configured."
try:
from agno.tools.mcp import MCPTools
global _issue_session
if _issue_session is None:
_issue_session = MCPTools(
server_params=_gitea_server_params(),
timeout_seconds=settings.mcp_timeout,
)
# Ensure connected
if not getattr(_issue_session, "_connected", False):
await _issue_session.connect()
_issue_session._connected = True
# Append auto-filing signature
full_body = body
if full_body:
full_body += "\n\n"
full_body += "---\n*Auto-filed by Timmy's thinking engine*"
# Parse owner/repo from settings
owner, repo = settings.gitea_repo.split("/", 1)
# Build tool arguments — gitea-mcp uses issue_write with method="create"
args = {
"method": "create",
"owner": owner,
"repo": repo,
"title": title,
"body": full_body,
}
# 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 []
category = "bug" if "bug" in label_list else "suggestion"
_bridge_to_work_order(title, body, category)
logger.info("Created Gitea issue via MCP: %s", title[:60])
return f"Created issue: {title}\n{result}"
except Exception as exc:
logger.warning("MCP issue creation failed: %s", exc)
return f"Failed to create issue via MCP: {exc}"
async def close_mcp_sessions() -> None:
"""Close any open MCP sessions. Called during app shutdown."""
global _issue_session
if _issue_session is not None:
try:
await _issue_session.close()
except Exception as exc:
logger.debug("MCP session close error: %s", exc)
_issue_session = None