Files
cowork-local/mcp_servers/ms365_server.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

107 lines
4.1 KiB
Python

"""Built-in MCP server for Microsoft 365 — ``python -m
cowork_local.mcp_servers.ms365_server``.
Wraps the existing Graph integration (``core/ms365_tools.build_ms365_tools``
→ ``core/ms365_graph``) as a standard stdio MCP server, so M365 tools reach
agents through the SAME MCP client layer as every external server
(``core/mcp_client.py``): calls are audited as ``kind="mcp_call"``, appear in
Monitoring's MCP Call History, and tool names arrive namespaced as
``ms365__<tool>`` (e.g. ``ms365__send_mail``).
Auth needs nothing new: the MSAL token cache lives in the OS credential
store (``core/ms365_auth.py``), which this subprocess shares with the GUI —
signing in via Settings → "Kết nối Microsoft 365" is enough.
Config is re-read from ``~/.cowork_local/config.json`` on EVERY list/call, so
toggling a connector (or signing out) in Settings applies on the next agent
turn without restarting this server.
"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
# Tool names inside this server drop the legacy "ms365_" prefix — the MCP
# client namespaces them "ms365__<name>", and "ms365__ms365_send_mail" would
# be silly. The legacy executor still dispatches by the prefixed name, so we
# strip on the way out and re-add on the way in.
_PREFIX = "ms365_"
def _strip(name: str) -> str:
"""Bỏ tiền tố ``ms365_`` khỏi tên tool.
MCP đã gom tool theo tên máy chủ nên để tiền tố nữa thành thừa; bộ thực
thi cũ vẫn dispatch theo tên có tiền tố, nên gỡ lúc ra và gắn lại lúc vào.
"""
return name[len(_PREFIX):] if name.startswith(_PREFIX) else name
def _fresh_tools() -> Tuple[list, Any]:
"""(specs, executor) from a FRESH config read — see module docstring."""
from cowork_local.config import AppConfig
from cowork_local.core.ms365_tools import build_ms365_tools
return build_ms365_tools(AppConfig.load())
def _tool_list() -> List[Dict[str, Any]]:
"""Plain-dict tool descriptions (name/description/inputSchema) — kept
SDK-type-free so tests can call it without an MCP session."""
specs, _executor = _fresh_tools()
return [{"name": _strip(s.name), "description": s.description,
"inputSchema": s.parameters} for s in specs]
def _dispatch(name: str, args: Dict[str, Any]) -> str:
"""Run one tool through the legacy executor; returns its output text or
raises RuntimeError (the MCP SDK turns that into an isError result)."""
_specs, executor = _fresh_tools()
if executor is None:
raise RuntimeError(
"Microsoft 365 is not available: not signed in, no connector enabled, "
"or external internet access is off (see Settings).")
result = executor(_PREFIX + _strip(name), args or {})
output = str(result.get("output", ""))
if not result.get("ok"):
raise RuntimeError(output or f"MS365 tool '{name}' failed.")
return output
def build_server():
"""Dựng máy chủ MCP cho nhóm tool MS365 và đăng ký hai handler của giao thức."""
import mcp.types as types
from mcp.server.lowlevel import Server
app = Server("ms365")
@app.list_tools()
async def list_tools() -> List["types.Tool"]:
"""Trả về danh sách tool MS365 hiện có, đọc từ cấu hình mới nhất."""
return [types.Tool(**t) for t in _tool_list()]
@app.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> List["types.TextContent"]:
"""Chạy một tool MS365 và trả kết quả về dưới dạng văn bản."""
return [types.TextContent(type="text", text=_dispatch(name, arguments or {}))]
return app
def main() -> None:
"""Điểm vào khi chạy như tiến trình con: phục vụ MCP qua stdio."""
import anyio
from mcp.server.stdio import stdio_server
app = build_server()
async def _run() -> None:
"""Vòng lặp phục vụ, đọc/ghi trên stdio cho tới khi tiến trình cha đóng."""
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
anyio.run(_run)
if __name__ == "__main__":
main()