Skip to main content
Glama
dhanushkacc

sharepoint-excel-mcp

by dhanushkacc
README.md
# sharepoint-excel-mcp

MCP server that gives an AI agent read access to Excel files stored in SharePoint
and OneDrive for Business, turning a permission-matrix spreadsheet into structured
JSON.

Built for a QA automation flow: a test-case-writing agent reads Jira stories, each
story references a permission matrix as a SharePoint link, and this server is what
turns that link into data the agent can reason over — unattended, with no user
sign-in.

---

## Contents

- [Quick start](#quick-start)
- [Azure app registration](#azure-app-registration)
- [Permissions](#permissions) ← **read this one**
- [Registering with Claude](#registering-with-claude)
- [Tools](#tools)
- [Behaviour worth knowing](#behaviour-worth-knowing)
- [Troubleshooting](#troubleshooting)
- [Limitations](#limitations)
- [Development](#development)

---

## Quick start

```bash
npm install
cp .env.example .env      # then fill in TENANT_ID / CLIENT_ID / CLIENT_SECRET
npm run build

npm run doctor -- "<your sharepoint link>"   # verify the whole chain
```

No Azure tenant yet? Everything below runs offline:

```bash
npm run mock              # full tool flow against fixtures
npm run mock -- --wac     # same, forcing the download fallback
npm run doctor -- --mock "https://contoso.sharepoint.com/:x:/s/QA/T?e=1"
npm test
```

---

## Azure app registration

1. **Entra admin centre → App registrations → New registration.** Name it, choose
   *Accounts in this organizational directory only*, leave the redirect URI blank —
   this is an app-only flow with no sign-in.
2. Copy the **Directory (tenant) ID** and **Application (client) ID** from the
   Overview page into `.env`.
3. **Certificates & secrets → New client secret.** Copy the secret **Value**
   immediately — not the Secret ID. It is shown once and cannot be retrieved later.
4. **API permissions → Add a permission → Microsoft Graph → Application
   permissions.** Add one of the permissions described below.
5. **Grant admin consent** for the directory. Without this, every Graph call fails
   with 403 no matter how correct the code is.

> **Admin consent is an external dependency.** If you are not a Global Admin or
> Privileged Role Admin you cannot grant it yourself, and no amount of retrying
> will work around it. A 403 almost always means this step is outstanding.

---

## Permissions

This is the part that decides how the server behaves, so it is worth understanding
rather than copying.

### The situation

Microsoft's own documentation for the Excel endpoints
([worksheets](https://learn.microsoft.com/en-us/graph/api/worksheet-list),
[usedRange](https://learn.microsoft.com/en-us/graph/api/worksheet-usedrange))
states **"Application: Not supported"**, and the
[Excel overview](https://learn.microsoft.com/en-us/graph/api/resources/excel)
documents only delegated scopes.

In practice app-only access *does* work with tenant-wide `Sites.Read.All`. Under
`Sites.Selected` it commonly fails with:

```
403 — Could not obtain a WAC access token
```

So the server has **two read paths** and picks automatically.

### Option A — `Sites.Selected` (default, least privilege)

| | |
|---|---|
| Graph permission | `Sites.Selected` (Application) |
| Access scope | Only sites you explicitly grant |
| Excel Workbook API | Usually **unavailable** (WAC 403) |
| Read path used | Download `.xlsx` + parse |

After granting admin consent, grant the app access to each site:

```bash
# Find the site ID
GET https://graph.microsoft.com/v1.0/sites/{tenant}.sharepoint.com:/sites/{siteName}

# Grant this app read access to that site
POST https://graph.microsoft.com/v1.0/sites/{siteId}/permissions
{
  "roles": ["read"],
  "grantedToIdentities": [
    { "application": { "id": "<CLIENT_ID>", "displayName": "sharepoint-excel-mcp" } }
  ]
}
```

**A WAC 403 under this option is expected and is not a misconfiguration.** It means
the download fallback engaged and everything is working. `npm run doctor` reports it
as an informational branch, not a failure.

### Option B — `Sites.Read.All` (workbook API available)

| | |
|---|---|
| Graph permission | `Sites.Read.All` (Application) |
| Access scope | **Every** SharePoint site in the tenant |
| Excel Workbook API | Available |
| Read path used | `usedRange` directly |

More capable and avoids downloading files, but it is a tenant-wide read grant. Your
security team may reasonably refuse it.

### Which to choose

Start with **A**. Both paths produce byte-identical output — there is a test
asserting exactly that — so the only differences are latency and breadth of access.
Move to **B** only if the download fallback proves too slow for your file sizes.

### OneDrive / personal sites

Files under `https://{tenant}-my.sharepoint.com/personal/...` are **not supported**.
`Sites.Selected` does not reach personal OneDrive, and it resolves through a
different Graph path. Put the matrix in a SharePoint site document library.

---

## Registering with Claude

```bash
claude mcp add sharepoint-excel \
  --env TENANT_ID=<tenant-id> \
  --env CLIENT_ID=<client-id> \
  --env CLIENT_SECRET=<client-secret> \
  -- node /absolute/path/to/share-point-mcp/dist/index.js
```

Run `npm run build` first. For development you can point at `tsx src/index.ts`
instead of the built output.

---

## Tools

### `resolve_link(link)`

Converts a link into stable `driveId` / `itemId`.

```json
{ "driveId": "b!…", "itemId": "01…", "name": "matrix.xlsx", "webUrl": "https://…" }
```

Call this **once** and reuse the IDs. Sharing links expire and get revoked;
resolving one costs an extra Graph round trip on every call.

Three link shapes are accepted:

| Shape | Example |
|---|---|
| Sharing link | `…/:x:/s/QA/EYt_abc123?e=xY9z` |
| Direct path | `…/sites/QA/Shared Documents/matrix.xlsx` |
| Library view | `…/AllItems.aspx?id=%2Fsites%2FQA%2F…` |

### `list_excel_sheets(link | driveId+itemId)`

Worksheet names. Uncapped.

### `describe_sheet(ref, sheet?, headerRow?)`

Headers, total row count, and up to 3 sample rows — **no full data dump**. This is
the intended first call against an unfamiliar matrix: see the shape, then build a
targeted filter.

### `get_permission_matrix(ref, sheet?, headerRow?, maxRows?, offset?, columns?, filter?)`

The main tool. Uses the header row as keys.

```json
{
  "file": "matrix.xlsx", "sheet": "Permission Matrix",
  "headers": ["Role", "Module", "Delete"],
  "rowCount": 8, "returnedCount": 3, "offset": 0, "truncated": true,
  "hint": "Showing 3 of 8 matching rows. Narrow with `filter` or `columns` first…",
  "records": [ { "Role": "Admin", "Module": "Billing", "Delete": true } ]
}
```

- `maxRows` — default 500
- `offset` — for paging
- `columns` — project to a subset; large saving on wide matrices
- `filter` — `{ column, equals? , contains? }`, case-insensitive

`rowCount` is the number of rows **matching the filter**, not the sheet size.

### `get_cell_range(ref, sheet, range)`

Raw values for an explicit A1 range like `"A1:D20"`, for sheets whose layout does
not fit the header-row model.

---

## Behaviour worth knowing

### `maxRows` / `offset` / `filter` / `columns` are applied client-side

They bound **what the agent receives**, not what crosses the network. `usedRange`
returns the entire grid regardless, so `maxRows: 10` on a 50,000-row sheet still
transfers all 50,000 rows.

This matters because the parameter names strongly imply server-side paging, and the
gap only shows up as unexplained latency on a large sheet. If you need to genuinely
bound the transfer, use **`get_cell_range`** with an explicit address — that is the
tool that actually does it.

### Dates come back as Excel serial numbers

A date cell reads as `45292`, not `"2024-01-01"`. That is what Graph's `usedRange`
returns, and the download fallback normalizes to match so the two paths cannot be
told apart. Convert on the consuming side:
`new Date(Date.UTC(1899, 11, 30) + serial * 86400000)`.

> Dates before 1900-03-01 are off by one, because Excel reproduces a Lotus 1-2-3 bug
> that treats 1900 as a leap year. Not corrected — see the test that pins it.

### Blank cells are empty strings

`""`, not `null` — again matching Graph. Fully blank rows are dropped entirely.

### Header normalization

Real matrices have messy header rows, and both failure modes are silent:

| Input | Becomes | Why |
|---|---|---|
| `"  Role  "` | `Role` | trimmed |
| `""` (blank) | `column_C` | named by its real sheet column |
| `Role`, `Role` | `Role`, `Role_2` | duplicates would otherwise overwrite |

### The 25 MB download ceiling

Under the fallback path, files above `MAX_DOWNLOAD_BYTES` (default 25 MB) are
refused **before** the transfer starts. exceljs loads the whole workbook into
memory; an OOM would kill the stdio transport and surface to the agent as a
connection drop rather than a readable error.

### Timeouts

30 s on Graph calls, 60 s on the download. A timeout is reported as its own error
type naming the request, and is **not** retried — 429 and 5xx are retried up to 3
times, 403 never is.

---

## Troubleshooting

**Run the doctor first.** It walks every leg and tells you which one broke:

```bash
npm run doctor -- "<your link>"
```

```
1. Configuration          2. Link                3. Access token
4. Resolve to driveItem   5. Workbook API        6. usedRange
7. Download fallback      → Verdict
```

It exits non-zero only when **neither** read path works.

| Symptom | Meaning |
|---|---|
| `Missing required environment variable` | `.env` not filled in |
| 401 at step 3 | Wrong tenant/client ID, or the secret's **ID** was copied instead of its **Value**, or it expired |
| 403 at step 4 | Admin consent not granted, or the site not granted under `Sites.Selected` |
| 404 at step 4 | Link revoked or expired. Confirm it opens in a browser — a revoked link and a bad share-ID encoding are indistinguishable from the response |
| Yellow `•` at steps 5–6 | **Expected under `Sites.Selected`.** Fallback engaged, nothing to fix |
| 403 at step 5 that is *not* yellow | A genuine consent problem, not a WAC issue |

`npm run encode -- "<link>"` prints the share ID and the exact Graph request to
paste into [Graph Explorer](https://developer.microsoft.com/graph/graph-explorer) —
useful for isolating an encoding problem from a permissions one.

---

## Limitations

- **`.xlsx` only.** `.xls` and `.csv` are not supported by the Excel REST APIs.
- **No consumer OneDrive.** Business platform only.
- **No personal OneDrive sites** (`/personal/…`). Use a SharePoint site library.
- **Read-only.** No write tools, by design.
- Dates are Excel serials; pre-1900-03-01 dates are off by one.

---

## Development

```bash
npm test          # 181 tests, no network
npm run typecheck # strict, includes tests and scripts
npm run lint
npm run dev       # run the server from source
npm run fixtures  # regenerate the .xlsx test fixture
```

### Layout

```
src/
  index.ts          MCP server + tool registration, nothing else
  config.ts         env parsing, fail-fast validation
  logger.ts         stderr-only logger
  auth.ts           token acquisition, caching, single-flight
  graph.ts          authed fetch, retry, error mapping, redaction
  links.ts          pure: link classification + share-ID encoding
  sharepoint.ts     resolution + the two-path read
  xlsx-fallback.ts  download + exceljs parse + cell normalization
  transform.ts      pure: grid → records
```

### Testing approach

Pure logic (share-ID encoding, link parsing, the transform pipeline, cell
normalization) is unit tested with no network. Graph interactions run against a
routing stub and recorded fixtures. `server.test.ts` drives the real server through
an in-memory MCP client, which catches registration mistakes unit tests cannot see.

The most important test is **cross-path equivalence**: the same fixture is read via
the workbook API and via the download fallback, and the results must be deeply
equal. Testing each path alone cannot catch a divergence between them — and it
caught a real one during development.

Nothing in `npm test` touches the network. Only `npm run doctor` talks to Azure.

### Stdout is the protocol

Under `StdioServerTransport`, stdout carries JSON-RPC frames. A stray `console.log`
in `src/` corrupts the stream and breaks the server with an opaque parse error on
the client. All logging goes to stderr via `logger.ts`, enforced by an eslint
`no-console` rule on `src/**`. Scripts under `scripts/` are exempt — they are CLIs.

TDQS

A4.3/5.0

Scored across 5 tools

Disambiguation5/5

Each tool addresses a distinct step in the workflow: resolve_link handles link-to-ID conversion, list_excel_sheets discovers sheets, describe_sheet inspects structure, get_permission_matrix reads structured data, and get_cell_range reads raw ranges. No two tools perform the same function.

Naming Consistency5/5

All tool names follow the verb_noun snake_case pattern (resolve_link, list_excel_sheets, describe_sheet, get_permission_matrix, get_cell_range). The verbs clearly indicate the action, and naming is uniformly consistent.

Tool Count5/5

Five tools is a well-scoped count for a focused Excel-reading server. Each tool covers a necessary operation without unnecessary bloat, and the set feels neither too thin nor too heavy.

Completeness4/5

The core read workflow is well-covered: resolving a link, listing sheets, inspecting structure, and reading data either as structured records or raw ranges. Minor gaps exist such as no write/update tools or a way to enumerate workbooks without a URL, but these appear outside the intended scope.

Maintenance

ActivitySlowing
ResponsivenessNo issues