feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能

- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置
- 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证
- 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数
- 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览
- 添加 Git 忽略文件配置和 Docker 挂载路径修复

BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
This commit is contained in:
2026-03-16 17:01:58 +08:00
parent 04501fea22
commit b3767dd4ab
20 changed files with 1671 additions and 83 deletions

View File

@ -21,7 +21,10 @@ from loguru import logger
from nanobot.authz.client import AuthzClient
from nanobot.config.schema import Config, MCPServerConfig
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook")
OUTLOOK_SERVER_ID = 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):
@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]:
}
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE))
safe_skip = max(0, int(skip))
return safe_top, safe_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):
normalized = dict(payload)
normalized["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 normalized
return {
**payload,
"page": {
"top": top,
"skip": skip,
"returned": returned,
"has_more": returned >= top,
"next_skip": skip + returned if returned >= top else None,
},
}
async def ensure_outlook_authz_permissions(config: Config) -> None:
backend_id = _require_backend_identity(config)
client = _authz_client(config)
@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
inbox = await _call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "inbox", "top": 8},
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
)
except Exception as exc: # noqa: BLE001
@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
sent = await _call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "sentitems", "top": 8},
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
)
except Exception as exc: # noqa: BLE001
@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
{
"start_time": start_of_day.isoformat(),
"end_time": end_of_day.isoformat(),
"top": 20,
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
"skip": 0,
},
scopes=["list_tools", "tool:calendar_list_events"],
)
@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]:
warnings: list[str] = []
try:
inbox = await provider.list_messages(folder="inbox", top=8)
inbox = await provider.list_messages(
folder="inbox",
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
skip=0,
)
except Exception as exc: # noqa: BLE001
inbox = {"value": []}
warnings.append(f"inbox unavailable: {exc}")
try:
sent = await provider.list_messages(folder="sentitems", top=8)
sent = await provider.list_messages(
folder="sentitems",
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
skip=0,
)
except Exception as exc: # noqa: BLE001
sent = {"value": []}
warnings.append(f"sent items unavailable: {exc}")
@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
calendar = await provider.list_events(
start_time=start_of_day.isoformat(),
end_time=end_of_day.isoformat(),
top=20,
top=OUTLOOK_OVERVIEW_EVENT_LIMIT,
skip=0,
)
except Exception as exc: # noqa: BLE001
calendar = {"value": []}
@ -825,6 +870,92 @@ async def get_message_detail(
return await provider.get_message(message_id=message_id, changekey=changekey)
async def list_messages(
config: Config,
*,
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)
if _use_authz_mode(config):
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),
}
input_data = _saved_connection_input(config.workspace_path)
provider, _normalized, _mods = _build_provider(input_data)
payload = await provider.list_messages(
folder=folder,
top=safe_top,
skip=safe_skip,
unread_only=unread_only,
)
return {
"folder": folder,
"unread_only": unread_only,
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
}
async def list_events(
config: Config,
*,
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)
if _use_authz_mode(config):
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),
}
input_data = _saved_connection_input(config.workspace_path)
provider, _normalized, _mods = _build_provider(input_data)
payload = await provider.list_events(
start_time=start_time,
end_time=end_time,
top=safe_top,
skip=safe_skip,
)
return {
"start_time": start_time,
"end_time": end_time,
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
}
def is_outlook_mcp_registered(config: Config) -> bool:
return OUTLOOK_SERVER_ID in config.tools.mcp_servers

View File

@ -2312,6 +2312,11 @@ def _register_routes(app: FastAPI) -> None:
server_id = req.id.strip()
if not server_id:
raise HTTPException(status_code=400, detail="Server id is required")
auth_mode = (req.auth_mode or "none").strip().lower() or "none"
auth_audience = (req.auth_audience or "").strip()
auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()]
if auth_mode == "oauth_backend_token" and not auth_audience:
auth_audience = f"mcp:{server_id}"
config.tools.mcp_servers[server_id] = MCPServerConfig(
command=req.command,
@ -2319,9 +2324,9 @@ def _register_routes(app: FastAPI) -> None:
env=req.env,
url=req.url,
headers=req.headers,
auth_mode=req.auth_mode,
auth_audience=req.auth_audience,
auth_scopes=req.auth_scopes,
auth_mode=auth_mode,
auth_audience=auth_audience,
auth_scopes=auth_scopes,
tool_timeout=req.tool_timeout,
sensitive=req.sensitive,
)
@ -2460,6 +2465,56 @@ def _register_routes(app: FastAPI) -> None:
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/messages")
async def get_outlook_messages(
folder: str = "inbox",
top: int = 20,
skip: int = 0,
unread_only: bool = False,
):
from nanobot.web.outlook import OutlookIntegrationError, list_messages
config: Config = app.state.config
if not folder.strip():
raise HTTPException(status_code=400, detail="folder is required")
try:
return await list_messages(
config,
folder=folder.strip(),
top=top,
skip=skip,
unread_only=unread_only,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/events")
async def get_outlook_events(
start_time: str,
end_time: str,
top: int = 20,
skip: int = 0,
):
from nanobot.web.outlook import OutlookIntegrationError, list_events
config: Config = app.state.config
if not start_time.strip() or not end_time.strip():
raise HTTPException(status_code=400, detail="start_time and end_time are required")
try:
return await list_events(
config,
start_time=start_time.strip(),
end_time=end_time.strip(),
top=top,
skip=skip,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/message-detail")
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail