sharepoint
sharepoint — MCP server for reading and indexing a SharePoint library
Reads a SharePoint folder, tells you what structured and unstructured data is in it, and (optionally) builds a local searchable index so questions get answered in milliseconds instead of by re-downloading files.
It does two separable jobs. You can use the first without ever touching the second.
LIVE mode | INDEXED mode | |
Source | Microsoft Graph, every call | local SQLite ( |
Freshness | always current | as of the last |
Cost per question | 1–N Graph calls + a download | one SQLite query (~50 ms) |
Answers | "what's in this folder", "show me this file" | "which document says X", ranked + cited |
Setup | sign in | sign in, then crawl once |
Read-only. Nothing here writes, moves, or deletes anything in SharePoint.
Install
cd <path>\mcp-sharepoint
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe selftest.py # 35 offline checks, no network
.\.venv\Scripts\python.exe setup_auth.py # one-time sign-in, from a REAL terminalRegister with Claude Code (user scope):
claude mcp add sharepoint --scope user -- `
"<path>\mcp-sharepoint\.venv\Scripts\python.exe" `
"<path>\mcp-sharepoint\server.py"Why setup_auth.py must run in a real terminal
Auth is WAM (the Windows account broker), which needs a window to parent its
dialog to. The MCP server starts headless, so the first interactive sign-in
has to happen once from a console. It writes .auth_record.json and a
DPAPI-encrypted MSAL cache; after that the server renews tokens silently and the
window stops mattering. If no cached sign-in exists and there's no console, the
server raises a clear error rather than letting the native broker kill the
process.
Permissions — the part that actually bites
The token needs delegated Files.Read.All (and Sites.Read.All to enumerate
sites and libraries). Two traps, both hit during the build of this server:
The Azure CLI client id (
04b07795-…) may not work. It is a handy default for Fabric/Power BI/ARM, but its Graph token can carry only directory scopes — noFiles.*, noSites.*. The symptom is not a 403:/sites/{id}/drivesreturns an empty list, and/sites/{id}/drivereturnsaccessDenied. Pointclient_idat an app registration that actually holds the SharePoint scopes.Device code flow is often blocked by Conditional Access. Use WAM (
"auth": "broker") or the browser redirect ("auth": "interactive").
config.json:
{
"auth": "broker", // broker | interactive | service-principal | service-principal-cert
"tenant_id": "<tenant-guid>",
"client_id": "<app with delegated Files.Read.All + Sites.Read.All>",
"login_hint": "you@contoso.com",
"hostname": "contoso.sharepoint.com",
"default_site": "Team/Sub", // 'Team', 'Team/Sub', a full URL, or a site id
"default_library": "Documents", // list_libraries tells you the real names
"max_download_mb": 80
}Delegated vs app-only is a security decision, not a convenience one. App-only
(service-principal*) reads every site the app is granted, so anyone who can
talk to the agent sees everything the app can see — permission trimming is gone.
Delegated means the server can only ever see what the signed-in user could see in
the browser. whoami reports which mode is live, and app-only mode says so in a
warning field. If app-only is unavoidable, use Sites.Selected with explicit
per-site grants instead of tenant-wide Sites.Read.All.
The config allowlist
config.json carries an allowlist that caps what the server will ask for:
"allowlist": {
"enabled": true,
"sites": ["Team/Sub"],
"libraries": ["Shared_Data"],
"folders": [] // empty = the whole library
}It is a guardrail, not a permission. The token decides what is reachable;
this decides what the server is willing to ask for. Anyone who can edit
config.json can widen it, so it is worth exactly nothing against a determined
user and quite a lot against an ordinary mistake. Real per-user trimming comes
from delegated auth.
It exists because an app-only identity typically holds tenant-wide
Sites.Read.All, which can mean hundreds of sites, including sensitive ones
such as HR. With the allowlist on, an ordinary question cannot wander into any
of them.
Enforced at every chokepoint, not just the front door: site resolution, library
resolution, raw drive ids (which would otherwise skip both), folder listing,
item fetch, library search and — last of all — download, immediately before
any bytes move. Verified by test: sites and libraries outside the allowlist
are refused, while the allowlisted library works, and passing a
foreign drive id directly to list_children, search_drive, get_item or
download is refused too.
list_sites filters rather than refuses — site discovery is how someone works
out what they may point at — and says how many results it withheld. whoami
reports the allowlist, and reports its absence as a warning when it is off.
App-only via Key Vault (optional)
If no delegated app id is available, the server can run as an app registration
that holds application Sites.Read.All, with its certificate never written
to disk:
{
"auth": "service-principal-cert",
"client_id": "<app-id>",
"operator_client_id": "<client id used for your own sign-in to Key Vault>",
"certificate_keyvault": {
"vault": "<vault-name>",
"cert_secret": "<pfx-secret-name>",
"password_secret": "<pfx-password-secret-name>"
}
}keyvault.py pulls the PKCS12 into memory at startup using your own WAM
sign-in, and hands it straight to CertificateCredential. Two consequences,
both wanted: no credential at rest in this folder, and whoever runs the server
must have get on the vault secret — revoking their Key Vault access revokes
their use of the app identity. (certificate_path + a password env var still
works if you prefer a local file.)
This mode is not permission-trimmed: it reads every site the app is
granted, regardless of who is asking. Prefer broker whenever a delegated
client id is available.
Related MCP server: file-analyzer-mcp
Tools
Discovery / live
Tool | What it does |
| signed-in identity, auth mode, trimming status, index summary |
| find sites by keyword |
| document libraries on a site, with size |
| one folder's contents, non-recursive |
| whole subtree profiled without downloading anything |
| live Microsoft Search across a library |
Single file
Tool | What it does |
| schema profile (structured) or outline + metadata (unstructured) |
| first N rows of a csv/xlsx/json/parquet |
| extracted text, with page ranges |
Index
Tool | What it does |
| crawl → parse → chunk → store → embed. Incremental by eTag |
| counts, vector coverage, backend, DB size, roots |
| embed chunks that have no vector yet |
| delete the local DB ( |
Retrieval
Tool | What it does |
| hybrid BM25 + vector search, ranked, with page citations |
| instant metadata query over the index |
| stored profile / text, no re-download |
| files where the secret/PII sweep matched, |
| re-run the sweep over indexed text after tuning patterns (no downloads) |
What "details" means per file type
Structured — csv tsv xlsx xlsm json jsonl parquet
Per sheet/table: row count, column count, and for every column its name,
inferred dtype, null %, distinct count, three sample values, min/max for
numerics, and a candidate_key flag when it is unique and non-null. Plus N
sample rows. CSV delimiter is sniffed (a pipe-delimited .csv profiles
correctly).
Unstructured — pdf docx pptx txt md html aspx xml eml
msg ipynb
Text with structure preserved: PDF keeps real page numbers, DOCX headings become
## lines and tables are flattened to a | b | c, PPTX keeps one page per
slide including speaker notes. A PDF with no text layer is flagged
needs OCR instead of being silently indexed as empty — otherwise a scanned
contract looks indexed and never matches anything.
Everything else — catalogued by metadata only (name, size, dates, author,
URL). Images, archives, media, .pbix are never downloaded.
Indexing and vectoring — the deep dive
Why an index at all
Without one, every question re-downloads and re-parses files, and a single 40-page PDF costs ~20k tokens of context. With one, the model sees ~8 passages of ~700 characters — the relevant ones — with citations. The index is what turns "read this folder" into "answer questions about this folder".
The pipeline
Graph walk ──► eTag check ──► download ──► parse ──► chunk ──► SQLite
(metadata) (skip if (only if (profile (~1400 ├─ files catalogue
unchanged) changed) or text) chars) ├─ chunks text
├─ chunks_fts BM25
└─ vectors float32Chunking. ~1400 characters with 200 overlap, split on paragraph boundaries, and never across a page boundary. That last rule is what lets every result cite "p. 7" honestly instead of approximately. Overlap exists so a sentence split across two chunks still matches in one of them.
Structured files get a "schema card" instead of their rows. One paragraph
per sheet: file name, path, sheet name, row/column counts, and every column with
its type and a sample value. Embedding the schema is what makes
"which file has closing dates by community?" find a spreadsheet nobody has
opened in a year. Indexing the rows themselves would flood the index with
millions of near-identical chunks and answer worse — if you want to query rows,
query the source system, not a vector store. (rows_as_chunks=True exists for
small reference tables; use it sparingly.)
Incremental. A file is re-parsed only when its SharePoint cTag changes, so
the second run over 5,000 files costs one download per edited file. Files deleted
from SharePoint are pruned from the index on each run.
Retrieval is hybrid, and that is not optional
finds | misses | |
BM25 (SQLite FTS5) | exact tokens: | paraphrase — "closing dates" will not match "possession schedule" |
Vectors (cosine) | meaning: "possession schedule", "handover timing" | rare literal strings; embeddings blur identifiers into noise |
Both run, and their ranks are fused with Reciprocal Rank Fusion
(score = Σ 1/(60 + rank)). RRF uses rank position, not score magnitude, which
sidesteps the usual way naive hybrid search goes wrong: BM25 scores and cosine
similarities live on incomparable scales, so any weighted-sum fusion needs
calibration that drifts the moment the corpus changes. Every result reports
whether it matched keyword, semantic or both — when results look wrong,
that field tells you which half to fix.
Embedding backends
Set embed_backend on index_folder, or leave it auto.
Backend | Quality | Cost | Setup | Notes |
| keyword only | £0 | none | BM25 over named business documents is genuinely decent. Honest default. |
| good | £0 |
| Local ONNX |
| best | ~$0.02 / 1M tokens | an AOAI deployment |
|
| best | same | an API key | Only if sending corpus text to |
auto resolves azure_openai → openai → fastembed → none.
Cost, concretely. 10,000 documents averaging 8 pages ≈ 40M tokens ≈
$0.80 one-off on text-embedding-3-small, plus a fraction of a cent per
query. Embedding cost is never the reason not to do this.
One hard rule the code enforces: you cannot mix embedding models in one
index. Vectors from two models share a space only by coincidence, and the
failure is silent — rankings just quietly get worse. embed_index refuses when
the stored backend and the live one disagree; reset_index and rebuild instead.
Sizing — where the local tier stops
Vectors are loaded into a NumPy matrix on first search and cached (invalidated on write). Search is an exact brute-force dot product — no ANN index, because exact is faster than approximate below ~10⁵ vectors and never has recall cliffs.
Corpus | Chunks | RAM at 384-d | RAM at 1536-d | Search |
1,000 files | ~25k | 38 MB | 150 MB | ~10 ms |
10,000 files | ~250k | 380 MB | 1.5 GB | ~80 ms |
50,000 files | ~1.2M | 1.8 GB | 7 GB | too slow |
Rule of thumb: the local tier is comfortable to about 200k chunks at 384-d (≈ 8,000 documents). Past that, graduate.
Lessons from real corpora
Duplicate file names are often real, not a bug — monthly snapshots kept side by side. Worth knowing before anyone "de-duplicates" a library.
A truncated crawl never prunes. When a crawl hits
max_files, pruning declines to run — a partial crawl that pruned would delete every file it never reached. Crawl a folder at a time, or raise the cap.A schema card is only as good as its column names. A table whose subject lives only in its folder name (
CycleTime/) is unreachable by either search half. Schema cards therefore carry a Subject terms line built from the path with camelCase and punctuation split out (CycleTime→Cycle Time).Near-duplicate documents drown a small table. When dozens of monthly reports say nearly the same thing, one schema card cannot out-rank them. Use
kind='structured'when you are looking for a table.Hybrid earns its keep, but not on every query. Where the halves agree, results are marked
bothand ranking is clearly better; on a repetitive corpus hybrid mostly reshuffles near-identical documents.matched_bytells you which case you are in.
When to graduate to Azure AI Search
Graduate when any of these becomes true:
more than ~10k documents, or the vector matrix no longer fits comfortably in RAM
more than one person uses it — a local index has no per-user permission trimming, so it would show every user everything the indexer could see
you want the index refreshed on a schedule rather than when you remember
you want semantic re-ranking, query rewriting, or answer synthesis
The migration is smaller than it looks, because the hard parts — extraction, chunking, schema cards — are the same. What changes is where chunks and vectors live. Two routes:
remoteSharePointknowledge source (2026-05-01-preview) — zero-copy: no index, no copy of the files. It calls the M365 Copilot Retrieval API under the covers, and query-time ACL and sensitivity-label trimming come free by passing the user's token inx-ms-query-source-authorization. Limits: 200 requests/user/hour, 25 results, text only.Push your own index — this server's extraction feeds an AI Search index directly. Note the native SharePoint-in-M365 indexer is not an option for tenants with Conditional Access: its limitations page says they are not supported.
And the part that makes the transition cheap: an Azure AI Search knowledge base
is itself an MCP server, at
https://<svc>.search.windows.net/knowledgebases/<kb>/mcp?api-version=2026-05-01-preview,
exposing a knowledge_base_retrieve tool. Claude Code points at that URL instead
of this server, and the tool contract barely changes. Check that your region
supports agentic retrieval and the semantic ranker.
Security
index_folder runs a redacting secret/PII sweep over every file's text:
passwords, connection strings, API keys, AWS keys, SAS signatures, private keys,
JWTs, SIN/SSN, card numbers. Matches are stored per file and surfaced by
risk_findings with the value redacted — all patterns are applied to every
snippet, because a flagged password line routinely sits next to an access key,
and a partial redaction is not a redaction.
Redaction is deliberately paranoid, for a reason found during the first real
crawl: detection patterns match the marker (-----BEGIN PRIVATE KEY-----,
pwd=), but the secret is the bytes after it, which no detection pattern
covers — so a context snippet still carried PEM key material. Two extra rules
now apply on top of every redaction:
opaque-run scrubbing (16+ base64/hex characters that don't look like a word), because a snippet is cut mid-token and a 64-character PEM line can arrive as 31 characters and slip under any length threshold;
when a file trips a high-severity pattern, every long run in every snippet from that file is scrubbed unconditionally — over-redacting a triage hint costs nothing, under-redacting republishes the key.
This is not a theoretical control: on a real 270-file crawl it flagged 6 files, two of them genuine plaintext credentials. The rest were documents that merely reference Key Vault secret names — correct practice, flagged for review, not findings. Expect roughly that ratio and triage accordingly.
Note what this does not fix: the index itself still contains the document
text, secrets included — that is inherent to any RAG index over a corpus like
this. Redaction protects the transcript, not the database. Treat data/index.db
as sensitive, and sweep the source folder.
Troubleshooting
Symptom | Cause |
| the client id has no |
Process exits with code 5, no traceback | WAM with no console window. Run |
|
|
| the error lists the real library names; use one |
PDF indexed with 0 useful chunks | no text layer — it is a scan. Needs OCR |
| device-code flow. Use broker or interactive |
Search returns nothing sensible | check |
Files
File | Role |
| MCP tool surface |
| auth + Microsoft Graph client |
| per-format extraction, profiling, chunking, secret sweep |
| SQLite catalogue + FTS5 + vectors + hybrid search |
| pluggable embedding backends |
| the crawl→index pipeline; also runnable headless |
| 35 offline checks |
| one-time interactive sign-in |
This server cannot be deployed
Maintenance
Related MCP Connectors
Find, read and reorganize files across Google Drive, OneDrive, SharePoint and Dropbox, with undo.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Securely search and manage workspace context files for AI agents and teams.
Verified OCR with per-value coordinates, plus a workspace agents can file documents into and query.
Related MCP Servers
- AlicenseAqualityCmaintenanceProvides LLMs with secure, read-only access to local documentation by scanning directories, extracting content from PDF, DOCX, Markdown, and text files, and performing keyword searches.37 npmMIT
- FlicenseNot gradedqualityCmaintenanceEnables local, read-only extraction of text and structure from PDF, DOCX, PPTX, SVG, and PNG files, including OCR for images, directory tree and metadata reporting, with strict path isolation and audit logging.-
- FlicenseBqualityAmaintenanceEnables local, offline document extraction and manipulation—PDF first but also HTML, DOCX, XLSX, PPTX, EML, EPUB, Markdown, and plain text—through tools for probing, locating, extracting, converting, assembling, OCR, protecting, and redacting documents, with nothing leaving the machine.7-
- AlicenseNot gradedqualityCmaintenanceEnables deterministic hybrid document retrieval (keyword, OCR, and optional semantic search) with persistent background sync jobs, cancellation, and health monitoring for local read-only document collections.MIT