Skip to main content
Glama
H1er0

Azure Files MCP

by H1er0

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. 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)): 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. 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 and the full reference table in 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

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

Build, type-check, test

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 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.

-
license - not tested
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

  • Read-only MCP access to sessions, funnels, campaigns, errors, live visitors, and anomalies.

  • Read-only CVE intelligence, remediation playbooks, and agent setup guides. Not a scanner.

  • Read-only access to your VortexIQ store data: audits, KPIs, alerts, Brand DNA, reports, Ask VIQ.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/H1er0/Azure-Files-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server