feat(outlook): 添加Outlook集成功能支持
添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理, 支持邮箱连接、断开、状态检查和数据同步等功能。 fix(config): 统一配置文件路径从.nanobot到.beaver 将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义, 确保所有组件使用一致的配置目录结构。 feat(agent): 添加代理删除功能和助手身份提示 为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示, 确保AI助手在交互中保持一致的身份认知。 feat(engine): 增强引擎循环并添加意图决策快照 扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录 决策快照,便于后续分析和调试。 feat(authz): 扩展认证客户端功能 为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统 的认证和授权能力。
This commit is contained in:
@ -48,3 +48,64 @@ class AuthzClient:
|
||||
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("GET", f"/backends/{backend_id}/permissions")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def set_permissions(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = await self._request("POST", f"/backends/{backend_id}/permissions", json_body=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def register_user(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
email: str | None = None,
|
||||
backend_name: str | None = None,
|
||||
backend_id: str | None = None,
|
||||
base_url: str | None = None,
|
||||
frontend_base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
optional = {
|
||||
"email": email,
|
||||
"backend_name": backend_name,
|
||||
"backend_id": backend_id,
|
||||
"base_url": base_url,
|
||||
"frontend_base_url": frontend_base_url,
|
||||
}
|
||||
payload.update({key: value for key, value in optional.items() if value})
|
||||
data = await self._request("POST", "/oauth/register", json_body=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def register_backend(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
base_url: str,
|
||||
frontend_base_url: str | None = None,
|
||||
backend_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"base_url": base_url,
|
||||
}
|
||||
if frontend_base_url:
|
||||
payload["frontend_base_url"] = frontend_base_url
|
||||
if backend_id:
|
||||
payload["backend_id"] = backend_id
|
||||
data = await self._request("POST", "/backends/register", json_body=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def get_outlook_settings(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("GET", f"/backends/{backend_id}/settings/outlook")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def set_outlook_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = await self._request("POST", f"/backends/{backend_id}/settings/outlook", json_body=payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
|
||||
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
@ -1,2 +1,527 @@
|
||||
"""Outlook integration."""
|
||||
"""Workspace-scoped Outlook helpers for the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from beaver.foundation.config import BeaverConfig
|
||||
from beaver.integrations.authz import AuthzClient
|
||||
|
||||
|
||||
OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID") or os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
|
||||
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
|
||||
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
|
||||
OUTLOOK_MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class OutlookIntegrationError(RuntimeError):
|
||||
"""Raised when the Outlook integration backend is unavailable or misconfigured."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutlookDefaults:
|
||||
domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "")
|
||||
service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "")
|
||||
server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "")
|
||||
default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
|
||||
autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutlookConnectionInput:
|
||||
email: str
|
||||
password: str
|
||||
username: str | None = None
|
||||
domain: str | None = None
|
||||
service_endpoint: str | None = None
|
||||
server: str | None = None
|
||||
autodiscover: bool = False
|
||||
default_timezone: str = "Asia/Shanghai"
|
||||
|
||||
|
||||
OUTLOOK_TOOL_NAMES = [
|
||||
"auth_status",
|
||||
"mail_list_folders",
|
||||
"mail_list_messages",
|
||||
"mail_search_messages",
|
||||
"mail_get_message",
|
||||
"mail_send_email",
|
||||
"mail_reply_to_message",
|
||||
"mail_forward_message",
|
||||
"mail_move_message",
|
||||
"mail_delta_sync",
|
||||
"calendar_list_events",
|
||||
"calendar_create_event",
|
||||
"calendar_update_event",
|
||||
"calendar_get_schedule",
|
||||
"calendar_find_meeting_times",
|
||||
"calendar_delta_sync",
|
||||
]
|
||||
|
||||
|
||||
def _call_timeout_seconds() -> float:
|
||||
raw = os.getenv("NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
|
||||
try:
|
||||
return max(1.0, float(raw)) if raw else 10.0
|
||||
except ValueError:
|
||||
return 10.0
|
||||
|
||||
|
||||
def _use_authz_mode(config: BeaverConfig) -> bool:
|
||||
return bool(config.authz.enabled and config.authz.base_url.strip())
|
||||
|
||||
|
||||
def _authz_client(config: BeaverConfig) -> AuthzClient:
|
||||
if not _use_authz_mode(config):
|
||||
raise OutlookIntegrationError("AuthZ mode is not enabled.")
|
||||
return AuthzClient(config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds))
|
||||
|
||||
|
||||
def _require_backend_identity(config: BeaverConfig) -> str:
|
||||
backend_id = config.backend_identity.backend_id.strip()
|
||||
client_id = config.backend_identity.client_id.strip()
|
||||
client_secret = config.backend_identity.client_secret.strip()
|
||||
if not (backend_id and client_id and client_secret):
|
||||
raise OutlookIntegrationError("Backend is not registered with AuthZ yet.")
|
||||
return backend_id
|
||||
|
||||
|
||||
def _outlook_mcp_url(config: BeaverConfig) -> str:
|
||||
url = config.authz.outlook_mcp_url.strip()
|
||||
if not url:
|
||||
raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.")
|
||||
return url
|
||||
|
||||
|
||||
def outlook_defaults() -> dict[str, Any]:
|
||||
return {
|
||||
"provider": "ews",
|
||||
"server_id": OUTLOOK_SERVER_ID,
|
||||
"mcp_command": os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
|
||||
"mcp_extra_args": shlex.split(os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
|
||||
"fields": asdict(OutlookDefaults()),
|
||||
}
|
||||
|
||||
|
||||
def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]:
|
||||
url = _outlook_mcp_url(config)
|
||||
return {
|
||||
"url": url,
|
||||
"authMode": "oauth_backend_token",
|
||||
"authAudience": f"mcp:{OUTLOOK_SERVER_ID}",
|
||||
"authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]],
|
||||
"sensitive": True,
|
||||
"toolTimeout": 60,
|
||||
"kind": "online",
|
||||
"category": "outlook",
|
||||
"managed": True,
|
||||
"displayName": "Outlook MCP",
|
||||
"source": "beaver-managed",
|
||||
}
|
||||
|
||||
|
||||
def _meta_file(workspace: Path) -> Path:
|
||||
return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json"
|
||||
|
||||
|
||||
def _load_meta(workspace: Path) -> dict[str, Any]:
|
||||
path = _meta_file(workspace)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]:
|
||||
payload = _load_meta(workspace)
|
||||
payload.update(fields)
|
||||
payload["updated_at"] = datetime.now().isoformat()
|
||||
path = _meta_file(workspace)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput:
|
||||
email = data.email.strip()
|
||||
password = data.password
|
||||
username = (data.username or "").strip() or email.partition("@")[0].strip()
|
||||
domain = (data.domain or "").strip() or None
|
||||
service_endpoint = (data.service_endpoint or "").strip() or None
|
||||
server = (data.server or "").strip() or None
|
||||
default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone
|
||||
if service_endpoint:
|
||||
server = None
|
||||
if not email:
|
||||
raise OutlookIntegrationError("Email is required.")
|
||||
if not password:
|
||||
raise OutlookIntegrationError("Password is required.")
|
||||
if not username:
|
||||
raise OutlookIntegrationError("Username is required.")
|
||||
if not data.autodiscover and not service_endpoint and not server:
|
||||
raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.")
|
||||
return OutlookConnectionInput(
|
||||
email=email,
|
||||
password=password,
|
||||
username=username,
|
||||
domain=domain,
|
||||
service_endpoint=service_endpoint,
|
||||
server=server,
|
||||
autodiscover=bool(data.autodiscover),
|
||||
default_timezone=default_timezone,
|
||||
)
|
||||
|
||||
|
||||
def _default_outlook_permissions() -> dict[str, Any]:
|
||||
return {
|
||||
"mcp": {
|
||||
OUTLOOK_SERVER_ID: {
|
||||
"enabled": True,
|
||||
"tools": list(OUTLOOK_TOOL_NAMES),
|
||||
}
|
||||
},
|
||||
"a2a": {"enabled": False, "agents": []},
|
||||
}
|
||||
|
||||
|
||||
async def ensure_outlook_authz_permissions(config: BeaverConfig) -> None:
|
||||
backend_id = _require_backend_identity(config)
|
||||
client = _authz_client(config)
|
||||
existing = await client.get_permissions(backend_id)
|
||||
mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {}
|
||||
if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"):
|
||||
return
|
||||
await client.set_permissions(backend_id, _default_outlook_permissions())
|
||||
|
||||
|
||||
async def _call_outlook_mcp_tool(
|
||||
config: BeaverConfig,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
scopes: list[str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from mcp import ClientSession, types
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
url = _outlook_mcp_url(config)
|
||||
client = _authz_client(config)
|
||||
try:
|
||||
token_response = await client.issue_token(
|
||||
client_id=config.backend_identity.client_id,
|
||||
client_secret=config.backend_identity.client_secret,
|
||||
audience=f"mcp:{OUTLOOK_SERVER_ID}",
|
||||
scopes=scopes or ["list_tools", f"tool:{tool_name}"],
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise OutlookIntegrationError("AuthZ token 请求超时。") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
detail = str(exc).strip() or exc.__class__.__name__
|
||||
raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc
|
||||
|
||||
access_token = str(token_response.get("access_token") or "").strip()
|
||||
if not access_token:
|
||||
raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.")
|
||||
|
||||
async def _invoke() -> dict[str, Any]:
|
||||
async with AsyncExitStack() as stack:
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
timeout=timeout_seconds or _call_timeout_seconds(),
|
||||
)
|
||||
)
|
||||
read, write, _ = await stack.enter_async_context(streamable_http_client(url, http_client=http_client))
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
result = await session.call_tool(tool_name, arguments=arguments)
|
||||
parts: list[str] = []
|
||||
for block in result.content:
|
||||
parts.append(block.text if isinstance(block, types.TextContent) else str(block))
|
||||
output = "\n".join(parts).strip()
|
||||
if not output:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"text": output}
|
||||
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||
|
||||
timeout_value = timeout_seconds or _call_timeout_seconds()
|
||||
try:
|
||||
return await asyncio.wait_for(_invoke(), timeout=timeout_value)
|
||||
except TimeoutError as exc:
|
||||
raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc
|
||||
except OutlookIntegrationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
detail = str(exc).strip() or exc.__class__.__name__
|
||||
raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc
|
||||
|
||||
|
||||
async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]:
|
||||
if not _use_authz_mode(config):
|
||||
raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.")
|
||||
normalized = _normalize_input(data)
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": "ews",
|
||||
"mailbox": normalized.email,
|
||||
"resolved_username": normalized.username or "",
|
||||
"resolved_domain": normalized.domain,
|
||||
"sample": {"folders": [], "inbox": [], "events": []},
|
||||
"warnings": [
|
||||
"AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save."
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]:
|
||||
probe = await test_connection(data, config)
|
||||
normalized = _normalize_input(data)
|
||||
backend_id = _require_backend_identity(config)
|
||||
client = _authz_client(config)
|
||||
await client.set_outlook_settings(
|
||||
backend_id,
|
||||
{
|
||||
"configured": True,
|
||||
"email": normalized.email,
|
||||
"username": normalized.username,
|
||||
"domain": normalized.domain,
|
||||
"service_endpoint": normalized.service_endpoint,
|
||||
"server": normalized.server,
|
||||
"autodiscover": normalized.autodiscover,
|
||||
"default_timezone": normalized.default_timezone,
|
||||
"password": normalized.password,
|
||||
},
|
||||
)
|
||||
await ensure_outlook_authz_permissions(config)
|
||||
meta = _update_meta(
|
||||
workspace,
|
||||
provider="ews",
|
||||
mailbox=normalized.email,
|
||||
last_verified_at=datetime.now().isoformat(),
|
||||
last_connected_at=datetime.now().isoformat(),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"probe": probe["sample"],
|
||||
"saved": {"backend_id": backend_id, "configured": True},
|
||||
"mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)},
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]:
|
||||
backend_id = _require_backend_identity(config)
|
||||
removed = False
|
||||
try:
|
||||
result = await _authz_client(config).delete_outlook_settings(backend_id)
|
||||
removed = bool(result.get("ok"))
|
||||
except Exception:
|
||||
removed = False
|
||||
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
|
||||
|
||||
|
||||
async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
||||
meta = _load_meta(workspace)
|
||||
if not _use_authz_mode(config):
|
||||
return {
|
||||
"configured": False,
|
||||
"connected": False,
|
||||
"provider": None,
|
||||
"storage_mode": "workspace",
|
||||
"saved": None,
|
||||
"auth_status": None,
|
||||
"mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers,
|
||||
"mcp_server_id": OUTLOOK_SERVER_ID,
|
||||
"defaults": outlook_defaults(),
|
||||
"meta": meta,
|
||||
"error": "Outlook setup requires AuthZ mode in this Beaver instance.",
|
||||
}
|
||||
|
||||
client = _authz_client(config)
|
||||
backend_id = _require_backend_identity(config)
|
||||
saved = await client.get_outlook_settings(backend_id)
|
||||
configured = bool(saved.get("configured"))
|
||||
connected = False
|
||||
auth_status: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
if configured:
|
||||
try:
|
||||
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
|
||||
connected = bool(auth_status.get("authenticated"))
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
return {
|
||||
"configured": configured,
|
||||
"connected": connected,
|
||||
"provider": "ews" if configured else None,
|
||||
"storage_mode": "authz",
|
||||
"saved": saved if configured else None,
|
||||
"auth_status": auth_status,
|
||||
"mcp_registered": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()),
|
||||
"mcp_server_id": OUTLOOK_SERVER_ID,
|
||||
"defaults": outlook_defaults(),
|
||||
"meta": meta,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
|
||||
saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config))
|
||||
if not saved.get("configured"):
|
||||
raise OutlookIntegrationError("Outlook is not configured for this backend.")
|
||||
timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai")
|
||||
now = datetime.now(ZoneInfo(timezone_name))
|
||||
start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo)
|
||||
end_of_day = start_of_day + timedelta(days=1)
|
||||
warnings: list[str] = []
|
||||
|
||||
async def _load_section(label: str, coro: Any) -> dict[str, Any]:
|
||||
try:
|
||||
payload = await coro
|
||||
return payload if isinstance(payload, dict) else {"value": []}
|
||||
except Exception as exc:
|
||||
warnings.append(f"{label} unavailable: {exc}")
|
||||
return {"value": []}
|
||||
|
||||
inbox, sent, calendar = await asyncio.gather(
|
||||
_load_section(
|
||||
"inbox",
|
||||
_call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
),
|
||||
),
|
||||
_load_section(
|
||||
"sent items",
|
||||
_call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
),
|
||||
),
|
||||
_load_section(
|
||||
"calendar",
|
||||
_call_outlook_mcp_tool(
|
||||
config,
|
||||
"calendar_list_events",
|
||||
{
|
||||
"start_time": start_of_day.isoformat(),
|
||||
"end_time": end_of_day.isoformat(),
|
||||
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
"skip": 0,
|
||||
},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
),
|
||||
),
|
||||
)
|
||||
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
|
||||
return {
|
||||
"mailbox": saved.get("email") or "",
|
||||
"timezone": timezone_name,
|
||||
"today": now.date().isoformat(),
|
||||
"connection": await outlook_status(config, workspace),
|
||||
"recentInbox": inbox.get("value", []),
|
||||
"recentSent": sent.get("value", []),
|
||||
"todayEvents": calendar.get("value", []),
|
||||
"warnings": warnings,
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
|
||||
return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(skip))
|
||||
|
||||
|
||||
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
|
||||
items = payload.get("value", []) if isinstance(payload, dict) else []
|
||||
returned = len(items) if isinstance(items, list) else 0
|
||||
page = payload.get("page") if isinstance(payload, dict) else None
|
||||
if isinstance(page, dict):
|
||||
return {
|
||||
**payload,
|
||||
"page": {
|
||||
"top": int(page.get("top", top)),
|
||||
"skip": int(page.get("skip", skip)),
|
||||
"returned": int(page.get("returned", returned)),
|
||||
"has_more": bool(page.get("has_more", False)),
|
||||
"next_skip": page.get("next_skip"),
|
||||
},
|
||||
}
|
||||
return {
|
||||
**payload,
|
||||
"page": {
|
||||
"top": top,
|
||||
"skip": skip,
|
||||
"returned": returned,
|
||||
"has_more": returned >= top,
|
||||
"next_skip": skip + returned if returned >= top else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def list_messages(
|
||||
config: BeaverConfig,
|
||||
*,
|
||||
folder: str,
|
||||
top: int,
|
||||
skip: int = 0,
|
||||
unread_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||
payload = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
return {"folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
|
||||
|
||||
|
||||
async def list_events(
|
||||
config: BeaverConfig,
|
||||
*,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
top: int,
|
||||
skip: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||
payload = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"calendar_list_events",
|
||||
{"start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
)
|
||||
return {"start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
|
||||
|
||||
|
||||
async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]:
|
||||
return await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_get_message",
|
||||
{"message_id": message_id, "changekey": changekey},
|
||||
scopes=["list_tools", "tool:mail_get_message"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user