Skip to content

[ADD] ms365: read shared O365 documents (Graph Shares + text extraction) - #185

Merged
beorngb merged 16 commits into
mainfrom
feat/ms365-shared-documents
Aug 27, 2026
Merged

[ADD] ms365: read shared O365 documents (Graph Shares + text extraction)#185
beorngb merged 16 commits into
mainfrom
feat/ms365-shared-documents

Conversation

@beorngb

@beorngb beorngb commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

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_path or the user's own OneDrive).

New tools

  • m365_read_shared(sharing_url | drive_id+item_id) — reads a shared document by
    share link (from an email) or by an item from m365_list_shared. Resolves via
    the 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, truncated flag), returning
    drive_id/item_id to pass to m365_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 400 Invalid hostname for this tenancy. Holding a B2B
guest 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

  • New plugins/ms365/extract.py: extract_text (docx/xlsx/pdf/text via
    python-docx/openpyxl/pypdf) and encode_sharing_url (Graph shareId).
  • m365_read_file/m365_read_drive_file extract Office text from binary
    content instead of returning "Binary file"; text files still decode
    utf-8-first.
  • Reads resolve the item metadata, then fetch the pre-authenticated
    @microsoft.graph.downloadUrl with no auth header (avoids handing the
    Graph bearer to the storage host), streamed under a 50 MB guard. Folders and
    items without a download URL are rejected with explicit errors.
  • Deps declared in the manifest and the ms365 pyproject extra.
  • Only the MCP-server path (server.py) is touched; the legacy in-process tag
    path is untouched.

Fixes found while exercising this against a live tenant

  • Files.Read.All added to the owner scope set. Plain Files.ReadWrite covers
    only 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.
  • The OAuth callback default pointed at /auth/ms365/callback, a route this
    router never served, so the prefilled value could never complete a login. It
    is now derived from GRIDBEAR_BASE_URL at the route that exists.
  • The MCP server is spawned as a bare script, so the new package import raised
    ModuleNotFoundError and killed the subprocess at startup. Tests importing
    the module as a package cannot observe that; the added test spawns the script
    the way the provider does.
  • mcp pinned <2.0.0. 2.0.0 renames Tool.inputSchema, and every stdio MCP
    server 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_by read createdBy, which Graph leaves null on sharedWithMe
    entries; the sharer is reported under shared.sharedBy.
  • m365_list_sites lost its search term, because httpx replaces a URL's
    existing query when params is also given, so site listings came back empty
    against tenants that do have sites.
  • OAuth failures were truncated to 100 characters and never logged, and a
    denied read blamed a missing scope even when the cause was a grant held in
    another tenant.

53 unit tests.

beorngb added 16 commits July 21, 2026 11:18
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.
@beorngb
beorngb merged commit bd3c98d into main Aug 27, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant