From 16fca4deb2bd73770f10b8fcb40e65acb9eaaba3 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 11:18:49 +0200 Subject: [PATCH 1/8] [ADD] ms365: encode_sharing_url (Graph shareId) --- plugins/ms365/extract.py | 14 ++++++++++++++ tests/unit/plugins/ms365/__init__.py | 0 tests/unit/plugins/ms365/test_extract.py | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 plugins/ms365/extract.py create mode 100644 tests/unit/plugins/ms365/__init__.py create mode 100644 tests/unit/plugins/ms365/test_extract.py diff --git a/plugins/ms365/extract.py b/plugins/ms365/extract.py new file mode 100644 index 0000000..b05f09d --- /dev/null +++ b/plugins/ms365/extract.py @@ -0,0 +1,14 @@ +"""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 + + +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("+", "-") 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..b8b1f68 --- /dev/null +++ b/tests/unit/plugins/ms365/test_extract.py @@ -0,0 +1,18 @@ +from plugins.ms365.extract import encode_sharing_url + + +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 From a600d2968e61a76b463307e0a63cab2b2b89dff1 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 11:27:11 +0200 Subject: [PATCH 2/8] [ADD] ms365: extract_text for docx/xlsx/pdf/text --- plugins/ms365/extract.py | 62 +++++++++++++++++++ tests/unit/plugins/ms365/test_extract.py | 76 +++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/plugins/ms365/extract.py b/plugins/ms365/extract.py index b05f09d..d59de33 100644 --- a/plugins/ms365/extract.py +++ b/plugins/ms365/extract.py @@ -2,6 +2,11 @@ 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: @@ -12,3 +17,60 @@ def encode_sharing_url(url: str) -> str: """ 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/tests/unit/plugins/ms365/test_extract.py b/tests/unit/plugins/ms365/test_extract.py index b8b1f68..3f5bc16 100644 --- a/tests/unit/plugins/ms365/test_extract.py +++ b/tests/unit/plugins/ms365/test_extract.py @@ -1,4 +1,6 @@ -from plugins.ms365.extract import encode_sharing_url +import io + +from plugins.ms365.extract import encode_sharing_url, extract_text def test_encode_sharing_url_microsoft_example(): @@ -16,3 +18,75 @@ def test_encode_sharing_url_is_url_safe_and_unpadded(): 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)") From b367d15e24ceef6bfde6dabfd3b4cd7a5158ab1f Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 11:41:10 +0200 Subject: [PATCH 3/8] [ADD] ms365: streaming download + item reader helpers --- plugins/ms365/server.py | 59 +++++++++ tests/unit/plugins/ms365/test_shared_tools.py | 122 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/unit/plugins/ms365/test_shared_tools.py diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index d5876a8..9577279 100644 --- a/plugins/ms365/server.py +++ b/plugins/ms365/server.py @@ -35,6 +35,7 @@ ROLE = os.environ.get("MS365_ROLE", "guest") GRAPH_API_BASE = "https://graph.microsoft.com/v1.0" +MAX_INLINE_READ_BYTES = 50 * 1024 * 1024 STATE_FILE = Path("/app/data/ms365_context.json") # Parse token data @@ -44,6 +45,10 @@ token_info = {} +class SharedReadError(Exception): + """Actionable, user-facing error from a shared-item read.""" + + class MS365Server: """MCP server for Microsoft 365 operations.""" @@ -460,6 +465,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.""" 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..c34e694 --- /dev/null +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -0,0 +1,122 @@ +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"}} + ) From 2b1a1893ca33940eb93444095d2f6e3c274d39ef Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 11:48:48 +0200 Subject: [PATCH 4/8] [ADD] ms365: m365_read_shared tool (shares + downloadUrl) --- plugins/ms365/server.py | 84 +++++++++++++++++++ tests/unit/plugins/ms365/test_shared_tools.py | 70 ++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index 9577279..acac1ee 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 @@ -155,6 +157,31 @@ 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_write_file", description="Write/upload file to SharePoint", @@ -637,6 +664,63 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: } 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)} + # NOTE: status-code detection below depends on _graph_request's + # exception message format: "Graph API error ({status}): {msg}". + # If that format changes, update these substring checks. + except Exception as err: + msg = str(err) + if "(403)" in msg: + return { + "success": False, + "error": ( + "access denied — the token likely lacks " + "Files.Read.All; re-authenticate this account " + "with the broader scope" + ), + } + if "(404)" in msg: + return { + "success": False, + "error": ( + "shared item not found — link expired/revoked, " + "or not shared to this account" + ), + } + return {"success": False, "error": f"read failed: {msg}"} + elif name == "m365_write_file": site_id = args["site_id"] file_path = args["file_path"].strip("/") diff --git a/tests/unit/plugins/ms365/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py index c34e694..24ad91c 100644 --- a/tests/unit/plugins/ms365/test_shared_tools.py +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -120,3 +120,73 @@ async def test_read_item_fallback_error_status(monkeypatch): 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"] From e0afa9637a9917aae9486179294e6f6478a77577 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 12:02:34 +0200 Subject: [PATCH 5/8] [ADD] ms365: m365_list_shared tool (paginated sharedWithMe) --- plugins/ms365/server.py | 67 +++++++++++++++ tests/unit/plugins/ms365/test_shared_tools.py | 86 +++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index acac1ee..281e238 100644 --- a/plugins/ms365/server.py +++ b/plugins/ms365/server.py @@ -38,6 +38,8 @@ 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 @@ -182,6 +184,14 @@ async def list_tools() -> list[Tool]: }, }, ), + 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", @@ -721,6 +731,63 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: } return {"success": False, "error": f"read failed: {msg}"} + 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, + } + # NOTE: status-code detection below depends on _graph_request's + # exception message format: "Graph API error ({status}): {msg}". + # If that format changes, update these substring checks. + except Exception as err: + msg = str(err) + if "(403)" in msg: + return { + "success": False, + "error": ( + "access denied — the token likely lacks " + "Files.Read.All; re-authenticate this account " + "with the broader scope" + ), + } + return {"success": False, "error": f"list failed: {msg}"} + elif name == "m365_write_file": site_id = args["site_id"] file_path = args["file_path"].strip("/") diff --git a/tests/unit/plugins/ms365/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py index 24ad91c..0ec606b 100644 --- a/tests/unit/plugins/ms365/test_shared_tools.py +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -190,3 +190,89 @@ async def test_read_shared_403_scope_message(): 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 + ) From fcc3c5f6996d79b27c59dfe4df62d1bd68db8566 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 12:09:22 +0200 Subject: [PATCH 6/8] [IMP] ms365: read_file/read_drive_file extract Office text --- plugins/ms365/server.py | 19 +++---- tests/unit/plugins/ms365/test_shared_tools.py | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index 281e238..c0c4643 100644 --- a/plugins/ms365/server.py +++ b/plugins/ms365/server.py @@ -664,14 +664,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 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": @@ -1082,11 +1079,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/tests/unit/plugins/ms365/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py index 0ec606b..3ffe062 100644 --- a/tests/unit/plugins/ms365/test_shared_tools.py +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -1,3 +1,4 @@ +import io from unittest.mock import AsyncMock import pytest @@ -276,3 +277,52 @@ async def test_list_shared_null_fields_robust(): 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"] From e179b5a492e1150f28dcd2d2202c21b5b4a91778 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 12:15:39 +0200 Subject: [PATCH 7/8] [ADD] ms365: wire shared tools (allowlist, context, deps) --- plugins/ms365/context.py | 4 ++++ plugins/ms365/manifest.json | 5 ++++- plugins/ms365/provider.py | 3 +++ pyproject.toml | 3 +++ tests/unit/plugins/ms365/test_shared_tools.py | 10 ++++++++++ 5 files changed, 24 insertions(+), 1 deletion(-) 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/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/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/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py index 3ffe062..3f96b9f 100644 --- a/tests/unit/plugins/ms365/test_shared_tools.py +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -326,3 +326,13 @@ async def test_read_file_plain_text_preserved(): "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 From cab300b68cb98b76630e677740db117168afc8d4 Mon Sep 17 00:00:00 2001 From: Beorn Date: Tue, 21 Jul 2026 14:02:21 +0200 Subject: [PATCH 8/8] [REF] ms365: consolidate Graph error mapping helper m365_read_shared and m365_list_shared duplicated the same fragile substring-based mapping from _graph_request exceptions to actionable error messages, each with an identical NOTE comment. Extract it into a single _graph_error_message() so the format assumption is documented and maintained in one place. --- plugins/ms365/server.py | 55 +++++++------------ tests/unit/plugins/ms365/test_shared_tools.py | 21 +++++++ 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/plugins/ms365/server.py b/plugins/ms365/server.py index c0c4643..e242eba 100644 --- a/plugins/ms365/server.py +++ b/plugins/ms365/server.py @@ -53,6 +53,23 @@ 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.""" @@ -704,29 +721,8 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: return {"success": True, "content": text, "name": fname} except SharedReadError as err: return {"success": False, "error": str(err)} - # NOTE: status-code detection below depends on _graph_request's - # exception message format: "Graph API error ({status}): {msg}". - # If that format changes, update these substring checks. except Exception as err: - msg = str(err) - if "(403)" in msg: - return { - "success": False, - "error": ( - "access denied — the token likely lacks " - "Files.Read.All; re-authenticate this account " - "with the broader scope" - ), - } - if "(404)" in msg: - return { - "success": False, - "error": ( - "shared item not found — link expired/revoked, " - "or not shared to this account" - ), - } - return {"success": False, "error": f"read failed: {msg}"} + return {"success": False, "error": _graph_error_message(err, "read")} elif name == "m365_list_shared": items = [] @@ -769,21 +765,8 @@ async def _call_tool_impl(self, name: str, args: dict) -> dict: "items": items, "truncated": truncated, } - # NOTE: status-code detection below depends on _graph_request's - # exception message format: "Graph API error ({status}): {msg}". - # If that format changes, update these substring checks. except Exception as err: - msg = str(err) - if "(403)" in msg: - return { - "success": False, - "error": ( - "access denied — the token likely lacks " - "Files.Read.All; re-authenticate this account " - "with the broader scope" - ), - } - return {"success": False, "error": f"list failed: {msg}"} + return {"success": False, "error": _graph_error_message(err, "list")} elif name == "m365_write_file": site_id = args["site_id"] diff --git a/tests/unit/plugins/ms365/test_shared_tools.py b/tests/unit/plugins/ms365/test_shared_tools.py index 3f96b9f..e7ae334 100644 --- a/tests/unit/plugins/ms365/test_shared_tools.py +++ b/tests/unit/plugins/ms365/test_shared_tools.py @@ -336,3 +336,24 @@ def test_new_tools_in_provider_allowlist(): 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"]