diff --git a/plugins/ms365/context.py b/plugins/ms365/context.py index d8d7bf6..3b94de1 100644 --- a/plugins/ms365/context.py +++ b/plugins/ms365/context.py @@ -23,6 +23,10 @@ - m365_read_drive_file(tenant, file_path) - Read file from OneDrive - m365_write_drive_file(tenant, file_path, content) - Write file to OneDrive +**Shared with me:** +- m365_list_shared() - List documents shared with you (Shared with me) +- m365_read_shared(sharing_url | drive_id, item_id) - Read a shared document's text + Use tags in your response to execute operations: [M365_LIST_SITES tenant="tenant_name"] [M365_LIST_FILES tenant="tenant_name" site_id="..." folder_path="/Documents"] diff --git a/plugins/ms365/extract.py b/plugins/ms365/extract.py new file mode 100644 index 0000000..d59de33 --- /dev/null +++ b/plugins/ms365/extract.py @@ -0,0 +1,76 @@ +"""Pure helpers for the ms365 MCP server: Office/PDF text extraction and +Microsoft Graph sharing-URL encoding. No plugin state, no I/O.""" + +import base64 +import io +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def encode_sharing_url(url: str) -> str: + """Encode a sharing URL into a Graph shareId (already `u!`-prefixed). + + Callers use it as `/shares/{enc}/driveItem` — NOT `/shares/u!{enc}` (that + would double the prefix to `u!u!…` and 400). + """ + b64 = base64.b64encode(url.encode("utf-8")).decode("ascii") + return "u!" + b64.rstrip("=").replace("/", "_").replace("+", "-") + + +_TEXT_EXTS = (".txt", ".md", ".csv", ".json", ".xml") + + +def _truncate(text: str, max_chars: int) -> str: + if len(text) > max_chars: + return text[:max_chars] + "\n...(truncated)" + return text + + +def extract_text(content: bytes, filename: str, max_chars: int = 200_000) -> str: + """Extract readable text from document bytes, sniffing by extension. + + Never raises: parse errors and unsupported/empty inputs return a readable + note. `filename` is only used for the extension; `content` is the bytes. + """ + ext = Path(filename).suffix.lower() + + if ext in _TEXT_EXTS: + return _truncate(content.decode("utf-8", errors="replace"), max_chars) + + if ext not in (".pdf", ".docx", ".xlsx", ".xls"): + return f"unsupported format ({ext or 'no extension'}) — cannot extract text" + + bio = io.BytesIO(content) + try: + if ext == ".pdf": + from pypdf import PdfReader + + reader = PdfReader(bio) + pages = [p.extract_text() for p in reader.pages] + text = "\n\n".join(p for p in pages if p) + elif ext == ".docx": + from docx import Document + + doc = Document(bio) + text = "\n\n".join(p.text for p in doc.paragraphs if p.text) + else: # .xlsx / .xls + from openpyxl import load_workbook + + wb = load_workbook(bio, read_only=True, data_only=True) + lines = [] + for sheet in wb.sheetnames: + ws = wb[sheet] + lines.append(f"[Sheet: {sheet}]") + for row in ws.iter_rows(values_only=True): + lines.append("\t".join("" if c is None else str(c) for c in row)) + wb.close() + text = "\n".join(lines) + except Exception as err: + logger.warning("ms365 extract_text failed for %s: %s", filename, err) + return f"could not parse {filename}: {type(err).__name__}: {err}" + + if not text or not text.strip(): + return "no extractable text (document may be image-only/scanned or empty)" + return _truncate(text, max_chars) diff --git a/plugins/ms365/manifest.json b/plugins/ms365/manifest.json index b0d796a..fc2b1a7 100644 --- a/plugins/ms365/manifest.json +++ b/plugins/ms365/manifest.json @@ -70,7 +70,10 @@ "dependencies": [ "msal", "httpx", - "cryptography" + "cryptography", + "python-docx", + "openpyxl", + "pypdf" ], "private_only": true } diff --git a/plugins/ms365/provider.py b/plugins/ms365/provider.py index 709226a..b35a4a0 100644 --- a/plugins/ms365/provider.py +++ b/plugins/ms365/provider.py @@ -167,6 +167,9 @@ def get_allowed_tools(self) -> list[str]: "m365_list_drive_files", "m365_read_drive_file", "m365_write_drive_file", + # Shared with me + "m365_list_shared", + "m365_read_shared", ] allowed = [] diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index d5876a8..e242eba 100644 --- a/plugins/ms365/server.py +++ b/plugins/ms365/server.py @@ -16,6 +16,8 @@ import httpx import msal +from plugins.ms365.extract import encode_sharing_url, extract_text + # MCP server imports try: from mcp.server import Server @@ -35,6 +37,9 @@ ROLE = os.environ.get("MS365_ROLE", "guest") GRAPH_API_BASE = "https://graph.microsoft.com/v1.0" +MAX_INLINE_READ_BYTES = 50 * 1024 * 1024 +_SHARED_MAX_ITEMS = 200 +_SHARED_MAX_PAGES = 5 STATE_FILE = Path("/app/data/ms365_context.json") # Parse token data @@ -44,6 +49,27 @@ token_info = {} +class SharedReadError(Exception): + """Actionable, user-facing error from a shared-item read.""" + + +def _graph_error_message(err: Exception, action: str) -> str: + """Map a _graph_request exception to an actionable message. + + NOTE: depends on _graph_request's message format + "Graph API error ({status}): {msg}". If that format changes, update this. + """ + msg = str(err) + if "(403)" in msg: + return ( + "access denied — the token likely lacks Files.Read.All; " + "re-authenticate this account with the broader scope" + ) + if "(404)" in msg: + return "not found — link expired/revoked, or not shared to this account" + return f"{action} failed: {msg}" + + class MS365Server: """MCP server for Microsoft 365 operations.""" @@ -150,6 +176,39 @@ async def list_tools() -> list[Tool]: "required": ["site_id", "file_path"], }, ), + Tool( + name="m365_read_shared", + description=( + "Read a document SHARED WITH the user — by share link " + "(from email) or by drive_id+item_id from m365_list_shared. " + "Returns extracted text for Word/Excel/PDF." + ), + inputSchema={ + "type": "object", + "properties": { + "sharing_url": { + "type": "string", + "description": "Share link URL (from an email/message)", + }, + "drive_id": { + "type": "string", + "description": "Drive ID (from m365_list_shared)", + }, + "item_id": { + "type": "string", + "description": "Item ID (from m365_list_shared)", + }, + }, + }, + ), + Tool( + name="m365_list_shared", + description=( + "List documents shared WITH the user (Shared with me). " + "Returns items with drive_id/item_id to pass to m365_read_shared." + ), + inputSchema={"type": "object", "properties": {}}, + ), Tool( name="m365_write_file", description="Write/upload file to SharePoint", @@ -460,6 +519,60 @@ async def _graph_request( return response.json() return response.content + async def _download_stream( + self, url: str, max_bytes: int = MAX_INLINE_READ_BYTES + ) -> bytes: + """Download a pre-authenticated URL with NO auth header, streaming with a + size guard. Aborts as soon as the running byte count exceeds max_bytes.""" + buf = bytearray() + async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client: + async with client.stream("GET", url) as resp: + if resp.status_code >= 400: + raise SharedReadError(f"download failed (HTTP {resp.status_code})") + clen = resp.headers.get("content-length") + if clen and clen.isdigit() and int(clen) > max_bytes: + raise SharedReadError( + f"file too large (>{max_bytes // (1024 * 1024)} MB) " + "to read inline" + ) + async for chunk in resp.aiter_bytes(): + buf.extend(chunk) + if len(buf) > max_bytes: + raise SharedReadError( + f"file too large (>{max_bytes // (1024 * 1024)} MB) " + "to read inline" + ) + return bytes(buf) + + async def _read_item(self, metadata: dict) -> tuple[bytes, str]: + """From a resolved driveItem's metadata, return (content_bytes, filename). + Guards folders; downloads via the pre-authenticated downloadUrl.""" + if metadata.get("folder") is not None: + raise SharedReadError("shared item is a folder, not a readable document") + name = metadata.get("name", "file") + download_url = metadata.get("@microsoft.graph.downloadUrl") + if download_url: + return await self._download_stream(download_url), name + # Fallback (rare): a file with no downloadUrl. Fetch /content without + # following the redirect, read the 302 Location, download it unauthenticated. + drive_id = (metadata.get("parentReference") or {}).get("driveId") + item_id = metadata.get("id") + if drive_id and item_id: + token = await self._get_valid_token() + async with httpx.AsyncClient( + base_url=GRAPH_API_BASE, follow_redirects=False, timeout=30.0 + ) as client: + resp = await client.get( + f"/drives/{drive_id}/items/{item_id}/content", + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code >= 400: + raise SharedReadError(f"cannot read item (HTTP {resp.status_code})") + loc = resp.headers.get("location") + if loc: + return await self._download_stream(loc), name + raise SharedReadError("cannot read this item (no download URL available)") + async def _call_tool_impl(self, name: str, args: dict) -> dict: """Execute tool and return result.""" @@ -568,16 +681,93 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: if isinstance(content, bytes): try: text = content.decode("utf-8") - if len(text) > 10000: - text = text[:10000] + "\n...(truncated)" - return {"success": True, "content": text, "file_path": file_path} + if len(text) > 50_000: + text = text[:50_000] + "\n...(truncated)" except UnicodeDecodeError: - return { - "success": False, - "error": "Binary file cannot be displayed as text", - } + text = extract_text(content, file_path, max_chars=50_000) + return {"success": True, "content": text, "file_path": file_path} return {"success": False, "error": "Could not read file"} + elif name == "m365_read_shared": + sharing_url = args.get("sharing_url") + drive_id = args.get("drive_id") + item_id = args.get("item_id") + has_pair = bool(drive_id) and bool(item_id) + if sharing_url and (drive_id or item_id): + return { + "success": False, + "error": "provide only one of: sharing_url, or drive_id+item_id", + } + if not sharing_url and (bool(drive_id) != bool(item_id)): + return { + "success": False, + "error": "drive_id and item_id must be supplied together", + } + if not sharing_url and not has_pair: + return { + "success": False, + "error": "provide either sharing_url or both drive_id and item_id", + } + try: + if sharing_url: + endpoint = f"/shares/{encode_sharing_url(sharing_url)}/driveItem" + else: + endpoint = f"/drives/{drive_id}/items/{item_id}" + metadata = await self._graph_request("GET", endpoint) + if not isinstance(metadata, dict): + return {"success": False, "error": "could not resolve shared item"} + content, fname = await self._read_item(metadata) + text = extract_text(content, fname, max_chars=50_000) + return {"success": True, "content": text, "name": fname} + except SharedReadError as err: + return {"success": False, "error": str(err)} + except Exception as err: + return {"success": False, "error": _graph_error_message(err, "read")} + + elif name == "m365_list_shared": + items = [] + truncated = False + endpoint = "/me/drive/sharedWithMe" + try: + for _page in range(_SHARED_MAX_PAGES): + data = await self._graph_request("GET", endpoint) + if not isinstance(data, dict): + break + for it in data.get("value", []): + remote = it.get("remoteItem") or {} + parent = remote.get("parentReference") or {} + created = remote.get("createdBy") or it.get("createdBy") or {} + shared_by = (created.get("user") or {}).get("displayName") + items.append( + { + "name": it.get("name"), + "shared_by": shared_by, + "last_modified": it.get("lastModifiedDateTime"), + "drive_id": parent.get("driveId"), + "item_id": remote.get("id"), + "web_url": it.get("webUrl"), + "is_folder": remote.get("folder") is not None, + } + ) + if len(items) >= _SHARED_MAX_ITEMS: + truncated = True + break + next_link = data.get("@odata.nextLink") + if truncated or not next_link: + break + endpoint = next_link # absolute URL — httpx uses it as-is + else: + # loop ran the full page cap without a break → more pages exist + truncated = True + return { + "success": True, + "count": len(items), + "items": items, + "truncated": truncated, + } + except Exception as err: + return {"success": False, "error": _graph_error_message(err, "list")} + elif name == "m365_write_file": site_id = args["site_id"] file_path = args["file_path"].strip("/") @@ -872,11 +1062,11 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: if isinstance(content, bytes): try: text = content.decode("utf-8") - if len(text) > 10000: - text = text[:10000] + "\n...(truncated)" - return {"success": True, "content": text, "file_path": file_path} + if len(text) > 50_000: + text = text[:50_000] + "\n...(truncated)" except UnicodeDecodeError: - return {"success": False, "error": "Binary file"} + text = extract_text(content, file_path, max_chars=50_000) + return {"success": True, "content": text, "file_path": file_path} return {"success": False, "error": "Could not read file"} elif name == "m365_write_drive_file": diff --git a/pyproject.toml b/pyproject.toml index d3bf2f8..7ed869e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ google = [ ] ms365 = [ "msal>=1.36.0", + "openpyxl>=3.1.0", + "pypdf>=4.0.0", + "python-docx>=1.2.0", ] livekit = [ "livekit>=1.0.0", diff --git a/tests/unit/plugins/ms365/__init__.py b/tests/unit/plugins/ms365/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/plugins/ms365/test_extract.py b/tests/unit/plugins/ms365/test_extract.py new file mode 100644 index 0000000..3f5bc16 --- /dev/null +++ b/tests/unit/plugins/ms365/test_extract.py @@ -0,0 +1,92 @@ +import io + +from plugins.ms365.extract import encode_sharing_url, extract_text + + +def test_encode_sharing_url_microsoft_example(): + # From Microsoft Graph docs: this exact URL encodes to this exact shareId. + url = "https://onedrive.live.com/redir?resid=1231244193912!12&authKey=Foo" + assert ( + encode_sharing_url(url) + == "u!aHR0cHM6Ly9vbmVkcml2ZS5saXZlLmNvbS9yZWRpcj9yZXNpZD0xMjMxMjQ0MTkzOTEyITEyJmF1dGhLZXk9Rm9v" + ) + + +def test_encode_sharing_url_is_url_safe_and_unpadded(): + # A URL whose base64 contains '+' and '/' must come back with '-'/'_' and no '='. + enc = encode_sharing_url("https://x/??>>>") # base64 of this contains + and / + assert enc.startswith("u!") + body = enc[2:] + assert "+" not in body and "/" not in body and "=" not in body + + +def _docx_bytes(paragraphs): + from docx import Document + + doc = Document() + for p in paragraphs: + doc.add_paragraph(p) + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +def _xlsx_bytes(rows): + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + for row in rows: + ws.append(row) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def test_extract_docx(): + out = extract_text(_docx_bytes(["Hello world", "Second line"]), "shared.docx") + assert "Hello world" in out and "Second line" in out + + +def test_extract_xlsx(): + out = extract_text(_xlsx_bytes([["Name", "Qty"], ["Widget", 5]]), "sheet.xlsx") + assert "Name" in out and "Widget" in out and "5" in out + + +def test_extract_txt(): + assert extract_text(b"plain text here", "notes.txt") == "plain text here" + + +def test_extract_uppercase_extension_g5(): + out = extract_text(_docx_bytes(["Case test"]), "SHARED.DOCX") + assert "Case test" in out + + +def test_extract_unsupported_format(): + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + out = extract_text(png, "image.png") + assert "unsupported format" in out.lower() and ".png" in out + + +def test_extract_empty_docx_g4(): + out = extract_text(_docx_bytes([]), "empty.docx") + assert out != "" + assert "no extractable text" in out.lower() + + +def test_extract_corrupt_docx_returns_note(): + out = extract_text(b"not a real docx", "broken.docx") + assert out != "" + assert "broken.docx" in out + + +def test_extract_no_extension_and_dotfile(): + assert "no extension" in extract_text(b"data", "README").lower() + assert "no extension" in extract_text(b"data", ".bashrc").lower() + + +def test_extract_truncation_marker(): + big = "x" * 300 + out = extract_text(big.encode(), "big.txt", max_chars=100) + assert out.endswith("...(truncated)") + assert len(out) <= 100 + len("\n...(truncated)") diff --git a/tests/unit/plugins/ms365/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py new file mode 100644 index 0000000..e7ae334 --- /dev/null +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -0,0 +1,359 @@ +import io +from unittest.mock import AsyncMock + +import pytest + +import plugins.ms365.server as srv + + +def _server(): + s = srv.MS365Server() + s._get_valid_token = AsyncMock(return_value="fake-token") + return s + + +class _FakeStream: + """Minimal async context manager mimicking httpx stream response.""" + + def __init__(self, chunks, status=200, headers=None): + self._chunks = chunks + self.status_code = status + self.headers = headers or {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def aiter_bytes(self): + for c in self._chunks: + yield c + + +class _FakeClient: + def __init__(self, stream): + self._stream = stream + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + def stream(self, method, url): + return self._stream + + +@pytest.mark.asyncio +async def test_download_stream_ok(monkeypatch): + s = _server() + stream = _FakeStream([b"abc", b"def"]) + monkeypatch.setattr(srv.httpx, "AsyncClient", lambda **kw: _FakeClient(stream)) + assert await s._download_stream("https://dl/x") == b"abcdef" + + +@pytest.mark.asyncio +async def test_download_stream_size_guard_streamed(monkeypatch): + s = _server() + # No Content-Length; over the cap only becomes clear while streaming. + stream = _FakeStream([b"x" * 10, b"x" * 10]) + monkeypatch.setattr(srv.httpx, "AsyncClient", lambda **kw: _FakeClient(stream)) + with pytest.raises(srv.SharedReadError, match="too large"): + await s._download_stream("https://dl/x", max_bytes=15) + + +@pytest.mark.asyncio +async def test_read_item_folder_guard(): + s = _server() + with pytest.raises(srv.SharedReadError, match="folder"): + await s._read_item({"name": "Docs", "folder": {"childCount": 3}}) + + +@pytest.mark.asyncio +async def test_read_item_downloads(monkeypatch): + s = _server() + monkeypatch.setattr(s, "_download_stream", AsyncMock(return_value=b"BYTES")) + content, name = await s._read_item( + {"name": "f.docx", "@microsoft.graph.downloadUrl": "https://dl/f"} + ) + assert content == b"BYTES" and name == "f.docx" + + +class _FakeResp: + def __init__(self, status, headers): + self.status_code = status + self.headers = headers + + +class _FakeGetClient: + def __init__(self, resp): + self._resp = resp + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, headers=None): + return self._resp + + +@pytest.mark.asyncio +async def test_read_item_fallback_302(monkeypatch): + s = _server() + getcli = _FakeGetClient(_FakeResp(302, {"location": "https://dl/redir"})) + monkeypatch.setattr(srv.httpx, "AsyncClient", lambda **kw: getcli) + monkeypatch.setattr(s, "_download_stream", AsyncMock(return_value=b"FB")) + content, name = await s._read_item( + {"name": "f.pdf", "id": "I1", "parentReference": {"driveId": "D1"}} + ) + assert content == b"FB" and name == "f.pdf" + + +@pytest.mark.asyncio +async def test_read_item_fallback_error_status(monkeypatch): + s = _server() + getcli = _FakeGetClient(_FakeResp(403, {})) + monkeypatch.setattr(srv.httpx, "AsyncClient", lambda **kw: getcli) + with pytest.raises(srv.SharedReadError, match="HTTP 403"): + await s._read_item( + {"name": "f.pdf", "id": "I1", "parentReference": {"driveId": "D1"}} + ) + + +@pytest.mark.asyncio +async def test_read_shared_by_link(monkeypatch): + s = _server() + s._graph_request = AsyncMock( + return_value={ + "name": "plan.docx", + "@microsoft.graph.downloadUrl": "https://dl/p", + } + ) + monkeypatch.setattr(s, "_download_stream", AsyncMock(return_value=b"raw")) + monkeypatch.setattr(srv, "extract_text", lambda b, n, **k: f"TEXT:{n}") + res = await s._call_tool_impl( + "m365_read_shared", {"sharing_url": "https://share/x"} + ) + assert res["success"] is True and res["content"] == "TEXT:plan.docx" + endpoint = s._graph_request.call_args[0][1] + assert endpoint.startswith("/shares/u!") and "u!u!" not in endpoint + + +@pytest.mark.asyncio +async def test_read_shared_by_drive_item(monkeypatch): + s = _server() + s._graph_request = AsyncMock( + return_value={"name": "s.xlsx", "@microsoft.graph.downloadUrl": "https://dl/s"} + ) + monkeypatch.setattr(s, "_download_stream", AsyncMock(return_value=b"raw")) + monkeypatch.setattr(srv, "extract_text", lambda b, n, **k: "OK") + res = await s._call_tool_impl( + "m365_read_shared", {"drive_id": "D1", "item_id": "I1"} + ) + assert res["success"] is True + assert s._graph_request.call_args[0][1] == "/drives/D1/items/I1" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "args,msg", + [ + ({}, "provide either"), + ({"drive_id": "D1"}, "supplied together"), + ({"item_id": "I1"}, "supplied together"), + ({"sharing_url": "u", "drive_id": "D1", "item_id": "I1"}, "only one"), + ], +) +async def test_read_shared_input_contract(args, msg): + s = _server() + s._graph_request = AsyncMock() + res = await s._call_tool_impl("m365_read_shared", args) + assert res["success"] is False and msg in res["error"] + s._graph_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_read_shared_folder_guard(): + s = _server() + s._graph_request = AsyncMock( + return_value={"name": "Dir", "folder": {"childCount": 2}} + ) + res = await s._call_tool_impl("m365_read_shared", {"sharing_url": "https://s/d"}) + assert res["success"] is False and "folder" in res["error"] + + +@pytest.mark.asyncio +async def test_read_shared_403_scope_message(): + s = _server() + s._graph_request = AsyncMock(side_effect=Exception("Graph API error (403): denied")) + res = await s._call_tool_impl("m365_read_shared", {"sharing_url": "https://s/d"}) + assert res["success"] is False and "Files.Read.All" in res["error"] + + +def _shared_item(name, drive, item, folder=False): + it = { + "name": name, + "webUrl": f"https://w/{item}", + "lastModifiedDateTime": "2026-07-20T10:00:00Z", + "remoteItem": { + "id": item, + "parentReference": {"driveId": drive}, + "createdBy": {"user": {"displayName": "Alice"}}, + }, + } + if folder: + it["remoteItem"]["folder"] = {"childCount": 1} + return it + + +@pytest.mark.asyncio +async def test_list_shared_single_page(): + s = _server() + s._graph_request = AsyncMock( + return_value={"value": [_shared_item("a.docx", "D", "I1")]} + ) + res = await s._call_tool_impl("m365_list_shared", {}) + assert res["success"] is True and res["count"] == 1 + row = res["items"][0] + assert row["name"] == "a.docx" and row["drive_id"] == "D" and row["item_id"] == "I1" + assert row["is_folder"] is False and row["shared_by"] == "Alice" + + +@pytest.mark.asyncio +async def test_list_shared_follows_nextlink(): + s = _server() + page1 = { + "value": [_shared_item("a", "D", "I1")], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/drive/sharedWithMe?$skip=1", + } + page2 = {"value": [_shared_item("b", "D", "I2")]} + s._graph_request = AsyncMock(side_effect=[page1, page2]) + res = await s._call_tool_impl("m365_list_shared", {}) + assert res["count"] == 2 and res.get("truncated") is not True + assert s._graph_request.call_args_list[1][0][1].startswith( + "https://graph.microsoft.com" + ) + + +@pytest.mark.asyncio +async def test_list_shared_truncates_at_cap(monkeypatch): + s = _server() + monkeypatch.setattr(srv, "_SHARED_MAX_PAGES", 2) + page = { + "value": [_shared_item("x", "D", "I")], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/next", + } + s._graph_request = AsyncMock(return_value=page) # always returns a nextLink + res = await s._call_tool_impl("m365_list_shared", {}) + assert res["truncated"] is True + assert s._graph_request.call_count == 2 # stopped at the page cap + + +@pytest.mark.asyncio +async def test_list_shared_null_fields_robust(): + s = _server() + s._graph_request = AsyncMock( + return_value={ + "value": [ + { + "name": "x", + "remoteItem": { + "id": "I", + "parentReference": None, + "createdBy": None, + }, + } + ] + } + ) + res = await s._call_tool_impl("m365_list_shared", {}) + assert res["success"] is True and res["count"] == 1 + row = res["items"][0] + assert ( + row["drive_id"] is None + and row["shared_by"] is None + and row["is_folder"] is False + ) + + +def _docx_bytes_local(text): + from docx import Document + + d = Document() + d.add_paragraph(text) + b = io.BytesIO() + d.save(b) + return b.getvalue() + + +@pytest.mark.asyncio +async def test_read_file_extracts_docx(): + s = _server() + s._graph_request = AsyncMock( + side_effect=[ + {"value": [{"id": "drv1"}]}, # drives lookup + _docx_bytes_local("Report body"), # /content bytes + ] + ) + res = await s._call_tool_impl( + "m365_read_file", {"site_id": "S", "file_path": "/r.docx"} + ) + assert res["success"] is True and "Report body" in res["content"] + + +@pytest.mark.asyncio +async def test_read_drive_file_extracts_docx(): + s = _server() + s._graph_request = AsyncMock(return_value=_docx_bytes_local("Drive doc")) + res = await s._call_tool_impl("m365_read_drive_file", {"file_path": "/d.docx"}) + assert res["success"] is True and "Drive doc" in res["content"] + + +@pytest.mark.asyncio +async def test_read_file_plain_text_preserved(): + # A non-Office utf-8 file (e.g. .py) must return its content (old behavior). + s = _server() + s._graph_request = AsyncMock( + side_effect=[ + {"value": [{"id": "drv1"}]}, + b"print('hello')", + ] + ) + res = await s._call_tool_impl( + "m365_read_file", {"site_id": "S", "file_path": "/app.py"} + ) + assert res["success"] is True and "print('hello')" in res["content"] + + +def test_new_tools_in_provider_allowlist(): + from plugins.ms365.provider import MS365Provider + + provider = MS365Provider({"client_id": "x"}) + provider._server_names = ["ms365-test"] + allowed = provider.get_allowed_tools() + assert "mcp__ms365-test__m365_list_shared" in allowed + assert "mcp__ms365-test__m365_read_shared" in allowed + + +def test_graph_error_message_helper(): + assert "Files.Read.All" in srv._graph_error_message( + Exception("Graph API error (403): x"), "read" + ) + assert "not found" in srv._graph_error_message( + Exception("Graph API error (404): x"), "read" + ) + assert ( + srv._graph_error_message(Exception("Graph API error (500): boom"), "list") + == "list failed: Graph API error (500): boom" + ) + + +@pytest.mark.asyncio +async def test_read_shared_404_message(): + s = _server() + s._graph_request = AsyncMock(side_effect=Exception("Graph API error (404): gone")) + res = await s._call_tool_impl("m365_read_shared", {"sharing_url": "https://s/d"}) + assert res["success"] is False and "not found" in res["error"]