Skip to main content
Glama
H1er0

Azure Files MCP

by H1er0
README.md
# Azure Files MCP (read-only)

A remote MCP server that gives Claude read-only access to one folder on an Azure Files
SMB share, where each connecting user sees only what their own NTFS permissions already
allow.

Two tools, nothing else:

- `list_directory(path)` - list files/folders under the configured root folder.
- `read_file(path)` - read a file's contents under the configured root folder. Plain text
  files return as-is; PDF, Word (`.docx`), and Excel (`.xlsx`) are converted to text
  automatically (see "Reading PDF/Word/Excel files" below).

There is no write, delete, or rename tool anywhere in this codebase - not stubbed out, not
disabled by config, just not present.

For step-by-step Azure/Entra setup instructions, see [SETUP.md](SETUP.md). This file covers
the architecture and design decisions; SETUP.md covers the click-by-click Portal walkthrough.

## Why this isn't quite what a first read of the ticket would suggest

The original design assumed: assign the non-privileged **Storage File Data SMB Share
Reader** RBAC role, then call Azure Files' FileREST API with each user's own OAuth token,
and Azure would enforce NTFS ACLs per user automatically.

That doesn't work. Verified directly against Microsoft's REST API docs
([Authorize with Microsoft Entra ID (REST API)](https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-azure-active-directory#permissions-for-file-service-operations)):
every FileREST read operation (List Directories and Files, Get File, Get File Properties,
...) requires **both** `.../files/read` **and** `.../readFileBackupSemantics/action`.
`readFileBackupSemantics/action` - Microsoft's own term for a mode that explicitly skips
NTFS ACL evaluation - is only granted by **Storage File Data Privileged Reader/Contributor**.
**Storage File Data SMB Share Reader doesn't appear in the REST permissions table at all** -
it only applies to genuine SMB protocol connections (port 445, Kerberos), which a Node
HTTPS backend calling FileREST cannot use.

So over REST, the role is either privileged (bypasses ACLs) or irrelevant. There's no way
to get Azure itself to enforce NTFS ACLs on a per-request REST/OAuth basis.

**What this server does instead:** uses **Storage File Data Privileged Reader** (still
read-only, still authenticated per-user - see below), and enforces NTFS permissions itself,
in code, using the real security descriptor Azure Files exposes for every file/folder:

1. Fetch the file/folder's NTFS permission as an SDDL string (`getPermission` REST call).
2. Parse the DACL into individual ACEs (`src/acl/sddl.ts` - hand-rolled; no maintained
   Node/TS library for this exists).
3. Resolve the calling user's own on-premises AD SID and every group SID they transitively
   belong to, via Microsoft Graph (`src/graph/sidResolver.ts`).
4. Evaluate the DACL against that SID set using real Windows AccessCheck semantics -
   explicit deny beats explicit allow, unmentioned bits are denied by default
   (`src/acl/evaluate.ts`).

This is genuinely per-user enforcement - just implemented here instead of delegated to
Azure's RBAC layer, because Azure has no REST-callable mechanism that does it for you.
**The real security boundary is this codebase, not Azure RBAC** - keep that in mind when
reasoning about anything below.

## Architecture

This server is its own OAuth 2.1 authorization server - Claude never talks to Entra
directly. That's a deliberate design choice, not the obvious one, so it's worth explaining
why: the MCP spec requires clients to send an RFC 8707 `resource` parameter equal to the
MCP server's own URL, and Entra only accepts a `resource` value that matches a *verified*
identifier URI on the app registration. Entra flatly refuses to register any
`*.azurewebsites.net` URL as one (unverified domain) - confirmed live as `AADSTS9010010`,
and unfixable by any Portal setting. So instead, Claude authorizes against *this server*
(whose own URL trivially satisfies the resource check), and the server relays the real
sign-in to Entra behind the scenes (`src/auth/mcpOAuthProvider.ts`), handing Claude back
Entra's real, unmodified access token. Downstream of that handoff, everything works with
a normal Entra-issued bearer token exactly as it would have otherwise.

Every read request:

1. Claude authorizes through this server's own `/authorize` and `/token` endpoints
   (`src/auth/mcpOAuthProvider.ts`), which relay the actual sign-in to Entra via a
   `/oauth/callback` route and hand back Entra's real access token (audience = this app's
   Entra app registration). This server only *validates* tokens on incoming requests
   (`src/auth/tokenVerifier.ts` - signature via Entra's JWKS, issuer, audience, expiry) -
   it never issues or signs one itself.
2. `path` is normalized and checked against the configured root folder
   (`src/files/pathScope.ts`) **before any Azure call** - a path that resolves outside the
   root is rejected regardless of what the token would otherwise permit.
3. The incoming user token is exchanged, via the OAuth2 on-behalf-of flow
   (`src/auth/obo.ts`, `@azure/identity`'s `OnBehalfOfCredential`), for a new token scoped to
   `https://storage.azure.com/.default`. **Every Azure Files call is made with this per-user
   token** (`src/files/shareClient.ts`) - never a shared service principal or static key.
4. The file/directory's NTFS permission is fetched and evaluated against the user's held
   SIDs (`src/graph/sidResolver.ts` + `src/acl/`). If access isn't granted, the tool returns
   a permission-denied error and nothing else.
5. Only if access is granted does the tool return the directory listing or file content
   already fetched in step 3 - converted to plain text first for PDF/Word/Excel (see below).
6. Every invocation - granted, denied, or errored - emits one structured audit log line
   (`src/audit/log.ts`) recording the user, the requested path, and the outcome. See
   "Audit logging" below.

Resolving the user's own SID/group SIDs uses an **app-only** Graph client-credentials call
(`src/graph/sidResolver.ts`), not OBO. That's deliberate: it's identity metadata (which
groups does this user belong to), not file data, so a shared app identity there doesn't
violate "never use a shared credential for reading file data" - the actual Azure Files
reads remain strictly per-user throughout.

Because `resolveHeldSids` runs on every single `list_directory`/`read_file` call, its
result (the user's own SID plus every transitive group SID) is cached in-memory per user
for `SID_CACHE_TTL_MS` (default 5 minutes, see `.env.example`) - a cache hit skips
Microsoft Graph entirely. Group membership changes rarely enough that this materially
cuts per-request latency and Graph load without meaningfully widening the staleness
window on a permission change. Set `SID_CACHE_TTL_MS=0` to disable caching (e.g. while
debugging a permissions change that isn't showing up).

The OAuth relay in step 1 tracks in-flight sign-ins in two short-lived, single-use
in-memory maps (`pendingAuthorizations`, `issuedCodes` in `mcpOAuthProvider.ts`). That's
fine for a single App Service instance, but means **this server must not be scaled beyond
one instance** without first moving that state to a shared store (e.g. Redis) - a second
instance would randomly fail sign-ins that started on a different instance than they
finished on.

## Reading PDF/Word/Excel files

`read_file` converts a handful of common binary document formats to plain text
server-side (`src/files/textExtract.ts`), since the MCP client rendering this connector's
results can't itself parse a binary "resource" blob for Claude to reason about - only text
content is actually readable in chat. Handled: `.pdf`, `.docx`, `.xlsx`. Not handled:
legacy binary `.doc`/`.xls` (pre-2007 Office formats), which fall back to returning an
unreadable blob, and scanned/image-only PDFs, which return a clear "no extractable text"
message rather than garbage (no OCR). Extraction output is capped independently of the raw
file-size cap (`MAX_READ_FILE_BYTES`), since a dense spreadsheet's text form can exceed its
binary size.

## Audit logging

Read access is enforced entirely in this server's own code (see above), not by Azure RBAC,
so there is no audit trail anywhere else - this server's audit log (`src/audit/log.ts`) is
it. Every `list_directory`/`read_file` call, regardless of outcome, emits exactly one JSON
line to stdout: timestamp, tool name, requested path, the calling user's oid and UPN, the
decision (`granted` / `denied` / `error`), a reason for anything other than `granted`, and
how long the call took. It's written as a plain `console.log` JSON line rather than through
a logging library, so it flows into whatever log pipeline the deployment target already
collects stdout into (e.g. Azure App Service's log stream / Log Analytics) with no extra
wiring.

## Required Azure/Entra configuration (not automated by this repo)

Full click-by-click steps are in [SETUP.md](SETUP.md). Summary of what's actually needed:

- **RBAC**: assign **Storage File Data Privileged Reader** (read-only; do *not* use
  Contributor) to the Entra group whose members should be able to use this connector,
  scoped to the storage account itself. This replaces the SMB Share Reader role from the
  original ticket - see rationale above. This is a coarse "can this person even ask" gate,
  not the real permission check - real NTFS permissions (enforced in code, see above) still
  govern what each user actually sees.
- **App registration**: one app registration does three jobs - OAuth client for Claude,
  identity for the On-Behalf-Of exchange to Azure Storage, and (normally) the app-only
  identity for Graph. It needs:
  - **Expose an API**: Application ID URI `api://<client-id>` (the default), with a scope
    named `access_as_user`.
  - **Authentication**: exactly one Web redirect URI, `<PUBLIC_BASE_URL>/oauth/callback` -
    this server's own callback, not Claude's. Claude's platform-wide callback
    (`https://claude.ai/api/mcp/auth_callback`) is never registered in Entra at all; see
    "Architecture" above for why.
  - A **client secret**.
  - This server does not support Dynamic Client Registration - it only ever recognizes one
    client (this app registration's own client ID/secret), which is also what you configure
    as the OAuth Client ID/Secret when adding this as a custom connector in Claude.
- **Graph API permissions** (application, admin-consented) on whichever app registration
  `GRAPH_CLIENT_ID` points at: `User.Read.All` and `GroupMember.Read.All` (or the broader
  `Directory.Read.All`) - needed to read `onPremisesSecurityIdentifier` for users and their
  transitive group memberships.

## Configuration

All settings are environment variables - see [.env.example](.env.example) and the full
reference table in [SETUP.md](SETUP.md). The important one for reuse: **`ROOT_PATH`** (plus
`STORAGE_ACCOUNT_NAME`/`SHARE_NAME`) is the *only* thing that needs to change to repoint
this server at a different folder, share, or client later. It's read once at process
startup and is never accepted as a tool parameter, so there's no way for a caller to widen
scope at runtime.

To repoint at a different folder/share:

1. Update `STORAGE_ACCOUNT_NAME`, `SHARE_NAME`, `ROOT_PATH` in App Service configuration.
2. Make sure the target Entra group has `Storage File Data Privileged Reader` on the new
   storage account.
3. Restart the app. No code or build changes needed.

## Running locally

```bash
npm install
cp .env.example .env   # fill in real values
npm run dev
```

## Build, type-check, test

```bash
npm run build   # tsc type-check + emit to dist/
npm test        # vitest - sddl parser, ACE evaluator, path-scope, SID cache, audit log unit tests
```

## Deploying to Azure App Service

See [SETUP.md](SETUP.md) for the full walkthrough. Short version:

1. Package `src/`, `package.json`, `package-lock.json`, and `tsconfig.json` into a zip -
   **never** a prebuilt `dist/` or `node_modules/`. Azure's Oryx builder compiles it fresh,
   server-side, on every deploy (requires the App Setting
   `SCM_DO_BUILD_DURING_DEPLOYMENT=true`).
2. Deploy the zip to a Linux App Service plan, Node 20+, **exactly one instance** (see the
   in-memory OAuth relay state note in "Architecture" above).
3. Set all variables from `.env.example` as App Service Application Settings (not a
   committed `.env` file).
4. `PUBLIC_BASE_URL` must be the App Service's real HTTPS URL, with **no trailing slash** -
   one produces double slashes in generated URLs and breaks the Entra redirect URI match.
5. Verify before connecting Claude: `GET /healthz` returns `ok`, and
   `GET /.well-known/oauth-protected-resource/mcp` returns a JSON metadata document (the
   `/mcp` suffix is required per RFC 9728, since the resource server URL itself has a `/mcp`
   path component).
6. The MCP endpoint Claude connects to is `POST {PUBLIC_BASE_URL}/mcp`.

This server hand-rolls OAuth (JWT validation, the `/.well-known/oauth-*` metadata endpoints,
and now the full authorization-server relay - via the MCP SDK's `mcpAuthRouter`) rather than
relying on App Service's built-in "Easy Auth" MCP integration. That integration is real but
still in preview, and Microsoft's own docs explicitly warn against forwarding its validated
token to a downstream resource - you'd still have to write the on-behalf-of exchange
yourself for Storage either way, so it wouldn't have reduced the amount of code here, just
added preview-stage risk.

## Known limitations

- **Domain-local AD groups may not resolve.** NTFS ACLs are matched against on-premises AD
  SIDs, resolved via Microsoft Graph's `onPremisesSecurityIdentifier`. Domain-local groups
  don't reliably sync/writeback to Entra ID, so an ACE granting access to an unsynced
  domain-local group can't be matched. This fails **closed**: an unresolved group SID can
  never satisfy an ALLOW ACE, so the worst case is a user seeing less than they're entitled
  to, never more (`src/graph/sidResolver.ts`, `src/acl/evaluate.ts`). If a target folder's
  ACLs use domain-local groups, verify during testing that this doesn't under-permission
  real users; if it does, the fix is either re-ACLing with universal/global groups or adding
  an LDAP fallback lookup (not implemented here).
- **Directory traversal (`FILE_TRAVERSE`) on ancestor folders isn't separately checked.**
  Windows grants "bypass traverse checking" to Authenticated Users by default in most real
  deployments, so this matches typical real-world behavior, but if a client's environment
  has non-default traverse restrictions on folders between the share root and `ROOT_PATH`,
  re-verify during testing.
- **Single App Service instance only** - see the OAuth relay note in "Architecture" above.
- **No write/delete/rename**, by design - not a gap, a deliberate constraint.
- **Legacy binary `.doc`/`.xls` and scanned/image-only PDFs aren't readable** - see
  "Reading PDF/Word/Excel files" above.
- **Requires the target tenant's on-prem AD to be synced to Entra** (Entra Connect / Cloud
  Sync) with on-premises SIDs flowing through - the whole per-user NTFS enforcement model
  depends on it. Won't work for a cloud-only, Entra-native tenant with no on-prem AD.