{
  "markdown": "# Microsoft Graph Atlas MCP\n\nAn MCP server that tracks schema changes across the Microsoft Graph API — both the changes Microsoft documents and the ones it doesn't. Covers the full Entra ID, Entra ID Governance, Identity & Access Management, Entra Agent ID, and Information Protection surface, seeded with a year of history at launch. Ships with an interactive [schema visualiser](#schema-visualiser) rendered inside the MCP client via MCP Apps.\n\n[![npm](https://img.shields.io/npm/v/graph-atlas-mcp)](https://www.npmjs.com/package/graph-atlas-mcp)\n[![npm downloads](https://img.shields.io/npm/dm/graph-atlas-mcp)](https://www.npmjs.com/package/graph-atlas-mcp)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n\n![](./img/Microsoft%20Graph%20Atlas%20MCP.png)\n\n## Why\n\nMicrosoft Graph evolves continuously across `v1.0` and `beta`. The official changelog is curated and incomplete — undocumented schema changes (new properties, removed relationships, new enum values) land in production before they're announced. This MCP closes that gap by diffing the actual `$metadata` CSDL daily, and backfills a year of history from both a public community tracker and the official changelog.\n\n## Data sources\n\n| Source | What it is | Granularity |\n|---|---|---|\n| `seed-entra-ms` | One-time import of [changes.entra.ms](https://changes.entra.ms/)'s historical CSDL diffs | Property-level |\n| `backfill-graph-changelog` | One-time scrape of Microsoft's official \"What's New\" history, classified into 8 IAM object families | Feature-level |\n| `self` | Daily `$metadata` fetch + diff, ongoing from first collection | Property-level |\n\nTwo further tables enrich every change with real-world permission/role context: `permissions` (1,036 scopes scraped from [Merill's Graph Permissions Explorer](https://graphpermissions.merill.net/permission/)) and `roles` (135 Entra built-in roles and their `microsoft.directory/*` actions, from the [Microsoft Learn permissions reference](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference)), cross-referenced into `role_permission_map`.\n\n## Quick start (npx)\n\nRequires **Node.js 22+** (uses the built-in `node:sqlite`). Add to your MCP client — e.g.\nClaude Desktop's `claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"graph-atlas\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"graph-atlas-mcp\"]\n    }\n  }\n}\n```\n\nThat's the whole setup: on first launch the server auto-downloads the latest published\ndatabase (change history, permissions, roles, embeddings) from this repo's GitHub Releases\ninto `~/.graph-atlas-mcp/`, and keeps it current against the daily release cadence.\n\n## Developing from source\n\n```bash\ngit clone https://github.com/darrenjrobinson/graph-atlas-mcp.git\ncd graph-atlas-mcp\nnpm install\nnpm run build\n```\n\n### Populate the database (first run)\n\n```bash\nnpm run seed                # one-off: import changes.entra.ms's ~1,600 historical records\nnpm run backfill            # re-runnable: scrape the official changelog for the 8 IAM families\nnpm run collect             # fetch v1.0 + beta $metadata, establish today's baseline snapshot\nnpm run collect-permissions # re-runnable, ~9 min: scrape ~1,036 Graph permission pages\nnpm run collect-roles       # re-runnable, ~1 min: scrape 135 Entra built-in roles + actions\nnpm run build-role-map      # re-runnable: cross-reference roles <-> permissions (see limitations)\n```\n\nThis produces `graph-atlas.db` in the project root. `npm run seed` uses a gitignored,\none-off local script (`scripts/seed-from-entra-ms.js`) — it isn't part of the committed repo.\n\nThe DB uses WAL journal mode so these collection scripts can run concurrently with an MCP\nclient that already has the file open (e.g. Claude Desktop) without lock contention.\n\n### Optional: semantic search\n\n`search_changes` runs in hybrid (keyword + semantic) mode automatically if `OPENAI_API_KEY`\nis set, and degrades to keyword-only otherwise:\n\n```bash\nexport OPENAI_API_KEY=sk-...\nnpm run embed      # generates embeddings for any change records that don't have one yet\n```\n\nRe-running `npm run embed` after a `collect` only embeds new records — it's incremental, not a full re-embed.\n\n### Connect a source checkout to an MCP client\n\nWhen developing, point the client at your build and local DB instead of the npm package:\n\n```json\n{\n  \"mcpServers\": {\n    \"graph-atlas\": {\n      \"command\": \"node\",\n      \"args\": [\"--experimental-sqlite\", \"/absolute/path/to/graph-atlas-mcp/dist/index.js\"],\n      \"env\": {\n        \"GRAPH_ATLAS_DB\": \"/absolute/path/to/graph-atlas-mcp/graph-atlas.db\"\n      }\n    }\n  }\n}\n```\n\n`GRAPH_ATLAS_DB` points the server at an explicit DB file. Without it, the server looks for\n`~/.graph-atlas-mcp/graph-atlas.db` (auto-downloaded from the latest GitHub Release), then\nfalls back to `./graph-atlas.db` in the current working directory.\n\n## Tools\n\n| Tool | Purpose |\n|---|---|\n| `search_changes` | Natural-language or keyword search across all sources. Hybrid (keyword + semantic via Reciprocal Rank Fusion) when an OpenAI key is configured, keyword-only otherwise. |\n| `get_recent_changes` | Structured query — filter by date, endpoint, object type/name, change kind, source. |\n| `get_object_history` | Full change history for one object (e.g. `group`, `accessPackage`), oldest to newest. Matches case-insensitively across sources. |\n| `get_change_detail` | Full detail for a single change record, plus related changes from the same day/object. |\n| `get_snapshot_summary` | Entity/property/enum counts per snapshot — \"how big is Graph API right now?\" |\n| `get_server_info` | About the server itself — running version, changelog entry, data freshness, and the two release channels (daily calendar-tagged database releases vs semver npm server releases). |\n| `get_permission_context` | Which permissions touch a Graph object, what each unlocks, who can grant them (heuristic — see limitations), and its recent changes. |\n| `schema_change_report` | Structured JSON (no UI) of the schema/permission/role graph — nodes + edges + change-activity counts, for reasoning over. |\n| `visualize_schema_graph` | Opens the interactive Graph Atlas visualiser inside the MCP client (via [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)) — a D3 force-directed graph with search, expand, and per-node detail. See below. |\n| `expand_schema_node` | Expands one node of the open visualiser (or feeds the model a one-node neighborhood delta) — powers the app's double-click/Expand interactions. |\n| `search_schema_objects` | Name search across entity types, permissions, and roles — resolves fuzzy names to canonical node ids; powers the app's search box. |\n| `get_node_timeline` | App-only: compact change timeline for the visualiser's detail panel (the model uses `get_object_history` instead). |\n\n### Example queries\n\n- \"What are the recent changes to Agent ID Graph APIs?\"\n- \"Have there been changes to groups APIs for nesting?\"\n- \"What's the history of the accessPackageSuggestion resource?\"\n- \"What changed in the last 7 days on the beta endpoint?\"\n- \"Show me sensitivity label or DLP policy changes\"\n\n## Schema visualiser\n\n`visualize_schema_graph` opens the Graph Atlas visualiser — a d3 force-directed SVG graph rendered\ndirectly inside the MCP client via [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)\n(Claude Desktop, ChatGPT, VS Code, and other compliant hosts). Its architecture and look & feel are\nported from [EntraPulse Polyarchy](https://github.com/darrenjrobinson/entrapulse-polyarchy):\n\n- **Dark-themed chrome** (light theme follows the host): pill view tabs, glass panels, a bottom\n  status bar with live node/edge/tool-call counts.\n- **Additive canvas + session cache** — the graph accumulates as you explore. Double-click any\n  node (or the panel's *Set as focus* button) to flip context to it: hop distances re-anchor, the\n  canvas glides to center it, and its neighborhood expands. Anything already fetched this session\n  re-expands instantly from cache with **zero repeat tool calls**. *Reset* clears the canvas but\n  keeps the cache.\n- **Search** — the toolbar search box (backed by `search_schema_objects`) matches entity types,\n  permissions, and roles by name and flips focus to your pick.\n- **Detail panel** — per-kind fields (properties/navigations/changes for entities, split\n  app/delegated consent + description for permissions, blast radius/published actions/template id\n  for roles), plus an in-panel change-history timeline (backed by `get_node_timeline`).\n- **Rearrangeable layout** — drag a node and it stays pinned where you drop it, so you can pull\n  clusters apart to read dense neighborhoods; new expansions bloom out of the node they came from.\n- **Color language** — entity types shade by distance from the focus (blue ramp); permissions and\n  roles wear their relationship colour (green *touches* / amber *grants*), faded with distance.\n  Red is reserved as a signal: a solid red ring marks privileged roles and admin-consent\n  permissions, and an amber/red activity dot marks recently-changed entities. A legend\n  (bottom-left) doubles as a visibility filter — unchecking a relationship or object type dims it.\n- **Model awareness** — the app pushes `updateModelContext` after every focus/expansion, so the\n  assistant knows what's on screen without extra tool calls.\n\nThree pivot dimensions, chosen via `view` (or the in-app tabs) so you can start wherever the\nquestion starts — a Role, a Permission, or an entity/API:\n\n- **`entity`** — Graph entity types; edges are navigation properties and inheritance.\n  `focus_object` = an entity like `group`. Expanding an entity on the Permission tab reveals the\n  permissions that touch it; on the Role tab it goes two hops — those permissions **plus the\n  roles that grant them** (so expanding `user` surfaces User Administrator, Helpdesk\n  Administrator, and friends).\n- **`permission`** — a permission scope, the entities it touches, and the roles that grant it.\n  `focus_object` = a permission like `User.Invite.All`.\n- **`role`** — an Entra role, the permissions it grants, and the entities those touch (two hops).\n  `focus_object` = a role like `User Administrator`. This is the one that answers \"what can this\n  role actually do\" / least-privilege comparison questions.\n\nBuild it with `npm run build` (server `tsc`, an app-ui typecheck, then esbuild bundles\n`app-ui/atlas-app/` into a single self-contained `dist/app-ui/atlas-app.html` — MCP App resources\nmust be one blob with no external network dependencies; the resource URI is\n`ui://graph-atlas/atlas-app.html`). The server reads the built HTML once at startup, so restart\nyour MCP client (or the server connection) after rebuilding.\n\n### Testing with MCP Jam\n\n```bash\n# published package\nnpx @mcpjam/inspector@latest npx -y graph-atlas-mcp\n\n# or a source checkout\nGRAPH_ATLAS_DB=/absolute/path/to/graph-atlas.db \\\n  npx @mcpjam/inspector@latest node --experimental-sqlite /absolute/path/to/dist/index.js\n```\n\nFor source checkouts all paths must be absolute — MCP Jam spawns the server from its own\nworking directory. Two\nMCP Jam (v2.34) quirks to know about: widgets only render under the **MCP Jam host profile**\n(the \"Claude\" host-emulation profile leaves the widget iframe stuck at \"loading\"), and the\nwidget's Sandbox tab / `debug/widget-visibility` trace events are the fastest way to diagnose a\nblank widget.\n\nNote: entity node ids are lowercase everywhere (`accessreview`), with display labels keeping CSDL\ncasing (`accessReview`) — entity names aren't consistently cased across the CSDL and the\npermission source data, and a canonical id is what lets one entity stay one node across all views.\n\nReal bugs caught and fixed while building this:\n- CSDL's short `graph.` namespace alias wasn't stripped (silently produced zero edges).\n- `permissions.resources` entity names aren't consistently cased across Merill's pages (would have\n  silently split one entity into two duplicate-looking nodes with a dangling edge between them).\n- MCP Apps' `autoResize` (on by default) sizes the iframe off the app's own document content\n  height — useless for a full-height flex app. The app now disables it and claims space explicitly\n  (fullscreen where supported, a tall inline frame otherwise).\n- The original force-graph (canvas) implementation called `zoomToFit` synchronously after\n  `graphData()` — before the debounced layout had assigned node positions — producing a NaN zoom\n  transform and a permanently blank canvas on every re-render after the first. The d3/SVG port\n  eliminates the bug class: the simulation is synchronous and centering guards unplaced nodes.\n- The UI sent its change-window picker values (`\"30\"`) where the server compares ISO dates\n  lexicographically, silently zeroing every change count. `since` now accepts both (day counts are\n  normalized server-side) and the UI converts to ISO dates anyway.\n- Browsers freeze `requestAnimationFrame` in hidden iframes, so a graph seeded while the host had\n  the widget hidden never got laid out by d3's simulation — every node rendered stacked at the\n  origin. New nodes now get explicit starting positions next to their expansion source, every\n  structural render ticks the layout synchronously once, and an `IntersectionObserver` reheats the\n  simulation when the canvas becomes visible again.\n- With `autoResize` off, some hosts (Claude Desktop's app surface) still size the iframe from the\n  app's reported height — the app now always reports a size after display-mode negotiation, using\n  the host's `containerDimensions` when published, and re-reports on host-context changes.\n\n## How it works\n\n1. **Daily collection** (`.github/workflows/collect.yml`, 02:00 UTC) fetches `$metadata` for both\n   endpoints — both are publicly accessible, **no Entra app registration required**.\n2. `scripts/parse-csdl.js` parses the CSDL XML into structured JSON (entity/complex/enum types,\n   properties, navigation properties, entity sets, singletons, functions/actions).\n3. `scripts/diff-snapshots.js` compares today's parse against the most recent stored snapshot\n   in `snapshots/v1.0/` and `snapshots/beta/` (committed to the repo as the audit trail).\n4. Detected changes are inserted into `graph-atlas.db` with `source = 'self'`; if any changes\n   were found, `scripts/create-release.js` publishes an updated GitHub Release.\n5. MCP clients auto-download the latest release on startup if newer than their local cache.\n\n## Known limitations\n\n- **Keyword search** uses `LIKE`-based matching, not real BM25 — Node's bundled `node:sqlite`\n  doesn't ship the FTS5 extension. Ranking is a simple term-coverage/frequency heuristic.\n- **Semantic search** requires `OPENAI_API_KEY`; vector storage uses [sqlite-vec](https://github.com/asg017/sqlite-vec) (bundled, no external vector DB).\n- **`$metadata` diffing** covers schema-level changes only — not behavioral, permission, or\n  endpoint-level changes that don't show up in the CSDL itself.\n- Backfilled changelog records are feature-level (\"Added the X resource type\"), not\n  property-level like the seed and self-collected data — the official changelog doesn't\n  include CSDL fragments.\n- **`get_permission_context` caps response size.** A common object like `user` matches 190+\n  permissions, and broad permissions like `Directory.ReadWrite.All` list 300+ endpoints — an\n  uncapped response hit 417KB in testing and caused a real MCP transport disconnect. Object-name\n  queries are capped to 25 permissions (narrowest-first by resource count, `limit` param up to\n  50) with `total_matching_count` telling you if more exist; each permission's `graph_endpoints`\n  is capped to 10 with `graph_endpoints_total_count`/`graph_endpoints_truncated`. Filter to a\n  specific `permission_name` to get one permission's full, untruncated detail.\n- **`grantable_by` (role<->permission mapping) is a heuristic correlation, not an authoritative\n  mapping.** Microsoft doesn't publish an official crosswalk between Entra RBAC actions\n  (`microsoft.directory/*`) and OAuth permission scopes (`User.Read.All`, etc.) — they're two\n  separate authorization systems that happen to govern overlapping resource types.\n  `scripts/build-role-permission-map.js` matches a permission's primary resource (derived from\n  its own name, e.g. `AdministrativeUnit.Read.All` -> `administrativeUnit`) against roles' actions\n  on that same resource. Verify `grantable_by` results before treating them as authoritative.\n  Matching is by **operation category** (read/create/update/delete/restore/enable/disable/invite/\n  license/session/all), not a coarse read-vs-write binary — an earlier binary version produced a\n  confirmed false positive (\"Directory Writers\" has create/update/enable/disable actions on\n  `users` but no delete/restore action, yet appeared grantable_by for `User.DeleteRestore.All`\n  simply because both were \"write-tier\"). Graph's own granular permission names\n  (`DeleteRestore`, `EnableDisableAccount`, `Invite`, `ReadUpdate`, ...) are operation-specific by\n  design, so `permissionRequiredCategories()` maps each verb segment to the specific action\n  categories that satisfy it — verified against Microsoft's built-in roles reference across the\n  full guest-lifecycle operation set (invite/create/update/enable-disable/delete-restore ×\n  Guest Inviter/Directory Writers/User Administrator, all 15 combinations exact). Unrecognized/\n  long-tail verbs (`ReadWrite`, `Write`, `Manage`, `FullControl`, and ~75 rarer Teams/Chat-specific\n  variants) fall back to \"any mutate action satisfies\" — a broad permission genuinely is satisfied\n  by partial write capability, so this remains a coarser signal for those specific verbs.\n- **A few Entra roles publish no RBAC actions at all** (currently the three Purview Workload\n  Content roles) — their permissions are managed outside Entra via Microsoft Purview role groups\n  and a first-party sync app, so no role↔permission mapping is possible. The visualiser and\n  search label these \"no published actions — managed outside Entra\" rather than showing a\n  misleading \"grants 0 permissions\".\n- Roles' Graph API route (`GET /roleManagement/directory/roleDefinitions`) requires an app\n  registration with `RoleManagement.Read.Directory` + `Application.Read.All` that this project\n  doesn't have configured — `collect-roles.js` uses the PRD's documented fallback (scraping the\n  public Microsoft Learn permissions reference) instead.\n- `permissions.combined_with` (some endpoints require multiple scopes together, e.g.\n  `Application.Read.All and Policy.Read.All`) isn't populated — Merill's page structure doesn't\n  expose this reliably enough to scrape.\n\n## Acknowledgments\n\nThis project builds on data and design work from others in the Entra community — credited\nhere as data sources and inspiration, not as project co-authors:\n\n- **Eric** — creator of [changes.entra.ms](https://changes.entra.ms/), the CSDL-diff tracker that seeds our historical change data\n- **Merill Fernando** — creator of the [Graph Permissions Explorer](https://graphpermissions.merill.net/permission/), the data source for permission enrichment (§6)\n- **EntraPulse Polyarchy** — Darren Robinson's own prior MCP; its d3 force-graph / MCP Apps architecture (design system, session cache, context-flip interaction model) is the foundation of the Graph Atlas visualiser\n\n## License\n\nMIT\n",
  "bytes": 19405,
  "sha": "5516ce8118f8a5c4ac371ba58e1f1650030e58cd7f218ad8df559d2b0ca68a27",
  "repo_slug": "darrenjrobinson/graph-atlas-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_darrenjrobinson_graph_atlas_mc_eb9bb6f0/readme"
}