[ADD] ms365: read shared O365 documents (Graph Shares + text extraction) - #185
Merged
Conversation
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.
Reading documents shared by other people goes through the Graph Shares API, which needs a scope covering files the user can access but does not own. Plain Files.ReadWrite is limited to the user's own drive and returns 403 on every shared item, so the shared-document tools were unusable for owner-role tenants. Scope strings moved to module constants behind a helper so the granted permission set is asserted by tests instead of buried in a handler.
The default redirect URI was http://localhost:8080/auth/ms365/callback, a route this router never served — the callback lives at /plugins/ms365/callback. Azure matches the redirect URI verbatim, so any deployment accepting the prefilled default failed the login with AADSTS50011 and had to be corrected by hand. The default is now derived from GRIDBEAR_BASE_URL, matching how the rest of the app builds externally reachable URLs, so a proxied deployment gets a working value without manual entry.
The provider spawns the MCP server as a bare script, so the repo root is absent from sys.path and the extract import raised ModuleNotFoundError, killing the subprocess at startup. Every existing test imports the module as a package, where the path already resolves, so none of them could see it — the added test spawns the script the way the provider does.
mcp 2.0.0 renames Tool.inputSchema to input_schema, which the gateway still reads under the old name, so every stdio MCP server failed to connect and agents were served an empty tool list. The dependency was unpinned, so a rebuild silently picked the new major.
All three failure paths in the callback dropped their detail into a 100-char query string and logged nothing, and the exception handler swallowed the traceback outright. Diagnosing a consent failure meant guessing which leg failed from the length of the message. The token-exchange path now records the redirect URI and scopes actually sent, which is what a mismatch against the app registration turns on.
Graph leaves createdBy null on /me/drive/sharedWithMe entries and reports the sharer under remoteItem.shared.sharedBy, so every listed document came back with shared_by empty — losing the one field that says who sent it. The existing fixture modelled the identity on createdBy, so the suite confirmed the assumption rather than the API; the added fixture mirrors a payload observed from live Graph.
The term was interpolated into the path while $top was passed as params, and httpx replaces a URL's existing query when params is given — so the request went out as /sites?$top=50 and Graph returned nothing. Every site listing came back empty against a tenant that has several. Passing search through params also encodes terms containing & or spaces, which the old interpolation broke.
A 403 was reported as a missing Files.Read.All scope, but an item held in another tenant — where the account's access is a guest grant a home-tenant token cannot use — fails the same way. The old wording sent the operator to re-authenticate an account that already carried the scope.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the ms365 MCP plugin to read Office 365 documents shared with the
account — the case the existing tools couldn't reach (they only read files by
known SharePoint
site_id + file_pathor the user's own OneDrive).New tools
m365_read_shared(sharing_url | drive_id+item_id)— reads a shared document byshare link (from an email) or by an item from
m365_list_shared. Resolves viathe Graph Shares API (
/shares/{shareId}/driveItem) or/drives/{id}/items/{id}, then returns extracted text.m365_list_shared()— lists "Shared with me" (/me/drive/sharedWithMe),paginated (cap 200 items / 5 pages,
truncatedflag), returningdrive_id/item_idto pass tom365_read_shared.What "shared" reaches
Both tools operate within the account's own tenant. A delegated token cannot
reach another tenant's SharePoint: the Shares API answers 403 and
/sites/{host}answers 400Invalid hostname for this tenancy. Holding a B2Bguest account in that tenant does not change this — the guest principal lives
there, not in the token's tenant. Reading documents held by another
organisation requires the app registered as multi-tenant and consented in that
tenant; the existing per-tenant config already covers that case (a tenant entry
carrying its
azure_id), so no code change would be needed.Also
plugins/ms365/extract.py:extract_text(docx/xlsx/pdf/text viapython-docx/openpyxl/pypdf) and
encode_sharing_url(Graph shareId).m365_read_file/m365_read_drive_fileextract Office text from binarycontent instead of returning "Binary file"; text files still decode
utf-8-first.
@microsoft.graph.downloadUrlwith no auth header (avoids handing theGraph bearer to the storage host), streamed under a 50 MB guard. Folders and
items without a download URL are rejected with explicit errors.
ms365pyproject extra.server.py) is touched; the legacy in-process tagpath is untouched.
Fixes found while exercising this against a live tenant
Files.Read.Alladded to the owner scope set. PlainFiles.ReadWritecoversonly the user's own drive, so the Shares API rejected every item shared by
somebody else and the feature could not work at all for owner-role tenants.
/auth/ms365/callback, a route thisrouter never served, so the prefilled value could never complete a login. It
is now derived from
GRIDBEAR_BASE_URLat the route that exists.ModuleNotFoundErrorand killed the subprocess at startup. Tests importingthe module as a package cannot observe that; the added test spawns the script
the way the provider does.
mcppinned<2.0.0. 2.0.0 renamesTool.inputSchema, and every stdio MCPserver then failed to connect while agents were served an empty tool list —
Migrate to mcp 2.x (Tool.inputSchema renamed to input_schema) #190 tracks the migration.
shared_byreadcreatedBy, which Graph leaves null onsharedWithMeentries; the sharer is reported under
shared.sharedBy.m365_list_siteslost its search term, because httpx replaces a URL'sexisting query when
paramsis also given, so site listings came back emptyagainst tenants that do have sites.
denied read blamed a missing scope even when the cause was a grant held in
another tenant.
53 unit tests.