Files
cowork-local/core/ms365_tools.py
2026-08-09 20:12:05 +07:00

260 lines
12 KiB
Python

"""Expose signed-in Microsoft 365 connectors (Outlook/Teams/OneDrive/
SharePoint/meeting transcripts) as tool specs + one executor callback.
Since the MCP upgrade, agents no longer receive these tools directly:
:func:`build_ms365_tools` now runs INSIDE the built-in MS365 MCP server
subprocess (``mcp_servers/ms365_server.py``), which re-exposes each spec as
an MCP tool — agents see them namespaced ``ms365__<name>`` (e.g.
``ms365__send_mail``) through the same MCP client layer as every external
server, and every call lands in the audit log as ``kind="mcp_call"``.
Returns ``([], None)`` whenever MS365 isn't signed in / no connector is
enabled, so the server exposes no tools until then.
"""
from __future__ import annotations
import json
from typing import Any, Callable, Dict, List, Optional, Tuple
from ..providers.base import ToolSpec
from . import ms365_graph as graph
from .ms365_auth import Ms365AuthError, get_access_token, signed_in_account
_CONNECTOR_SPECS: Dict[str, List[ToolSpec]] = {
"outlook": [
ToolSpec(
name="ms365_list_mail",
description="List recent Outlook mail (subject, sender, received time, preview).",
parameters={"type": "object", "properties": {
"top": {"type": "integer", "description": "Max messages, default 10"},
"folder": {"type": "string", "description": "Mail folder, default 'inbox'"},
}},
),
ToolSpec(
name="ms365_send_mail",
description="Send an email from the signed-in Outlook account.",
parameters={"type": "object", "properties": {
"to": {"type": "string", "description": "Comma-separated recipient addresses"},
"subject": {"type": "string"},
"body": {"type": "string"},
}, "required": ["to", "subject", "body"]},
),
ToolSpec(
name="ms365_list_calendar_events",
description="List upcoming Outlook calendar events.",
parameters={"type": "object", "properties": {
"top": {"type": "integer", "description": "Max events, default 10"},
}},
),
],
"teams": [
ToolSpec(
name="ms365_list_teams",
description="List the Microsoft Teams the signed-in user has joined.",
parameters={"type": "object", "properties": {}},
),
ToolSpec(
name="ms365_list_channels",
description="List channels of a Microsoft Team.",
parameters={"type": "object", "properties": {
"team_id": {"type": "string"},
}, "required": ["team_id"]},
),
ToolSpec(
name="ms365_list_channel_messages",
description="List recent messages in a Teams channel.",
parameters={"type": "object", "properties": {
"team_id": {"type": "string"}, "channel_id": {"type": "string"},
"top": {"type": "integer", "description": "Max messages, default 20"},
}, "required": ["team_id", "channel_id"]},
),
ToolSpec(
name="ms365_send_channel_message",
description="Post a message to a Teams channel.",
parameters={"type": "object", "properties": {
"team_id": {"type": "string"}, "channel_id": {"type": "string"},
"text": {"type": "string"},
}, "required": ["team_id", "channel_id", "text"]},
),
ToolSpec(
name="ms365_send_to_connected_teams",
description=("Send a message to the Teams channel/chat the user connected via a "
"pasted link in Settings — no team/channel/chat id needed."),
parameters={"type": "object", "properties": {
"text": {"type": "string"},
}, "required": ["text"]},
),
],
"onedrive": [
ToolSpec(
name="ms365_list_onedrive_files",
description="List files/folders in the signed-in user's OneDrive.",
parameters={"type": "object", "properties": {
"path": {"type": "string", "description": "Relative folder path, default root"},
}},
),
ToolSpec(
name="ms365_read_onedrive_file",
description="Read a text file's content from OneDrive.",
parameters={"type": "object", "properties": {
"path": {"type": "string"},
}, "required": ["path"]},
),
ToolSpec(
name="ms365_write_onedrive_file",
description="Create/overwrite a small text file on OneDrive.",
parameters={"type": "object", "properties": {
"path": {"type": "string"}, "content": {"type": "string"},
}, "required": ["path", "content"]},
),
],
"sharepoint": [
ToolSpec(
name="ms365_list_sharepoint_sites",
description="Search SharePoint sites by keyword.",
parameters={"type": "object", "properties": {
"query": {"type": "string"},
}, "required": ["query"]},
),
ToolSpec(
name="ms365_list_sharepoint_files",
description="List files/folders in a SharePoint site's document library.",
parameters={"type": "object", "properties": {
"site_id": {"type": "string"}, "path": {"type": "string"},
}, "required": ["site_id"]},
),
],
"meeting_transcript": [
ToolSpec(
name="ms365_find_online_meeting",
description="Find a Teams online meeting by its join URL (to get its meeting id).",
parameters={"type": "object", "properties": {
"join_url": {"type": "string"},
}, "required": ["join_url"]},
),
ToolSpec(
name="ms365_list_meeting_transcripts",
description="List available transcripts for a Teams meeting.",
parameters={"type": "object", "properties": {
"meeting_id": {"type": "string"},
}, "required": ["meeting_id"]},
),
ToolSpec(
name="ms365_get_meeting_transcript",
description="Fetch the text content of a Teams meeting transcript.",
parameters={"type": "object", "properties": {
"meeting_id": {"type": "string"}, "transcript_id": {"type": "string"},
}, "required": ["meeting_id", "transcript_id"]},
),
],
}
# Tool names that send/write data somewhere outside this machine — the Code
# tab's permission gate (core/code_agent.py) must treat these exactly like
# write_file/run_command: confirm in "confirm" mode, never advertise in Plan
# mode. Every other ms365 tool is read-only (list/read) and safe to
# auto-approve like the rest of the read-only tool set.
# NOTE: these are the MCP-QUALIFIED names the agent actually sees
# ("<server>__<tool>", server "ms365", prefix stripped by the built-in
# server — see mcp_servers/ms365_server.py), NOT the internal ms365_* names
# the executor below dispatches on.
MS365_WRITE_TOOLS = {
"ms365__send_mail",
"ms365__send_channel_message",
"ms365__send_to_connected_teams",
"ms365__write_onedrive_file",
}
_MAX_OUTPUT_CHARS = 20_000
def _dump(data: Any) -> str:
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
if len(text) > _MAX_OUTPUT_CHARS:
text = text[:_MAX_OUTPUT_CHARS] + f"\n…(truncated to {_MAX_OUTPUT_CHARS} chars)…"
return text
def build_ms365_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[str, dict], dict]]]:
"""Tool specs + executor for whichever MS365 connectors are enabled AND
signed in. Returns ``([], None)`` when nothing is signed in / enabled, or
when "Allow external internet access" is off, so the agent never even
sees these tools — this is the authoritative enforcement of that switch
(the Settings UI also disables the connector checkboxes live, but this
check is what actually stops a Graph call from happening even if a
connector was left ``true`` in a hand-edited or stale config.json)."""
ms365 = config.ms365
if not ms365.get("allow_external_internet"):
return [], None
tenant_id, client_id = ms365.get("tenant_id", ""), ms365.get("client_id", "")
if not signed_in_account(tenant_id, client_id):
return [], None
connectors = ms365.get("connectors", {})
specs: List[ToolSpec] = []
for key, group in _CONNECTOR_SPECS.items():
if connectors.get(key):
specs.extend(group)
if not specs:
return [], None
def executor(name: str, args: dict) -> dict:
args = args or {}
try:
token = get_access_token(tenant_id, client_id)
if name == "ms365_list_mail":
return {"ok": True, "output": _dump(graph.list_mail(
token, args.get("top", 10), args.get("folder", "inbox")))}
if name == "ms365_send_mail":
graph.send_mail(token, args["to"], args["subject"], args["body"])
return {"ok": True, "output": "Mail sent."}
if name == "ms365_list_calendar_events":
return {"ok": True,
"output": _dump(graph.list_calendar_events(token, args.get("top", 10)))}
if name == "ms365_list_teams":
return {"ok": True, "output": _dump(graph.list_teams(token))}
if name == "ms365_list_channels":
return {"ok": True, "output": _dump(graph.list_channels(token, args["team_id"]))}
if name == "ms365_list_channel_messages":
return {"ok": True, "output": _dump(graph.list_channel_messages(
token, args["team_id"], args["channel_id"], args.get("top", 20)))}
if name == "ms365_send_channel_message":
graph.send_channel_message(token, args["team_id"], args["channel_id"], args["text"])
return {"ok": True, "output": "Message posted."}
if name == "ms365_send_to_connected_teams":
target = ms365.get("teams_target")
if not target:
return {"ok": False,
"output": "No Teams chat/channel connected yet — paste a link in Settings first."}
if target.get("kind") == "channel":
graph.send_channel_message(token, target["team_id"], target["channel_id"], args["text"])
else:
graph.send_chat_message(token, target["chat_id"], args["text"])
return {"ok": True, "output": "Message sent to the connected Teams chat/channel."}
if name == "ms365_list_onedrive_files":
return {"ok": True,
"output": _dump(graph.list_onedrive_files(token, args.get("path", "")))}
if name == "ms365_read_onedrive_file":
return {"ok": True, "output": graph.read_onedrive_file(token, args["path"])}
if name == "ms365_write_onedrive_file":
return {"ok": True, "output": _dump(graph.write_onedrive_file(
token, args["path"], args["content"]))}
if name == "ms365_list_sharepoint_sites":
return {"ok": True, "output": _dump(graph.list_sharepoint_sites(token, args["query"]))}
if name == "ms365_list_sharepoint_files":
return {"ok": True, "output": _dump(graph.list_sharepoint_files(
token, args["site_id"], args.get("path", "")))}
if name == "ms365_find_online_meeting":
return {"ok": True, "output": _dump(graph.find_online_meeting(token, args["join_url"]))}
if name == "ms365_list_meeting_transcripts":
return {"ok": True,
"output": _dump(graph.list_meeting_transcripts(token, args["meeting_id"]))}
if name == "ms365_get_meeting_transcript":
return {"ok": True, "output": graph.get_meeting_transcript_content(
token, args["meeting_id"], args["transcript_id"])}
return {"ok": False, "output": f"Unknown MS365 tool: {name}"}
except (Ms365AuthError, graph.Ms365GraphError, KeyError) as exc:
return {"ok": False, "output": str(exc)}
except Exception as exc: # defensive: a tool must never crash the agent
return {"ok": False, "output": f"Error running {name}: {exc}"}
return specs, executor