Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions plugins/ms365/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
76 changes: 76 additions & 0 deletions plugins/ms365/extract.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion plugins/ms365/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@
"dependencies": [
"msal",
"httpx",
"cryptography"
"cryptography",
"python-docx",
"openpyxl",
"pypdf"
],
"private_only": true
}
3 changes: 3 additions & 0 deletions plugins/ms365/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
212 changes: 201 additions & 11 deletions plugins/ms365/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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("/")
Expand Down Expand Up @@ -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":
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Empty file.
Loading