365center-mcp 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 365center-mcp
2
2
 
3
- **MCP server for Microsoft 365 / SharePoint — 35 tools for full read/write access**
3
+ **MCP server for Microsoft 365 / SharePoint — 30+ tools for full read/write access**
4
4
 
5
5
  Available on [GitHub](https://github.com/Crscristi28/365center-mcp) · [npm](https://www.npmjs.com/package/365center-mcp) · [Docker Hub](https://hub.docker.com/r/crscristi28/365center-mcp) · [cristianb.cz](https://cristianb.cz)
6
6
 
@@ -42,7 +42,7 @@ Full visual walkthrough: **[Setup Guide PDF](https://github.com/Crscristi28/365c
42
42
 
43
43
  `365center-mcp` is a Model Context Protocol (MCP) server that gives Claude — and any other MCP-compatible AI client — full read/write access to Microsoft 365 SharePoint sites.
44
44
 
45
- It exposes **35 tools** covering SharePoint sites, document libraries, documents, pages, metadata columns, navigation, and permissions. Claude can list sites, upload and download files, tag documents, create and publish pages, build navigation menus, manage permissions, and more — all through a single MCP connection.
45
+ It exposes **36 tools** covering SharePoint sites, document libraries, documents, pages, metadata columns, navigation, and permissions. Claude can list sites, upload and download files, tag documents, create and publish pages, build navigation menus, manage permissions, and more — all through a single MCP connection.
46
46
 
47
47
  Built for manufacturing companies managing factory documentation in SharePoint, but works with any Microsoft 365 tenant.
48
48
 
@@ -56,7 +56,7 @@ Built for manufacturing companies managing factory documentation in SharePoint,
56
56
 
57
57
  ## Features
58
58
 
59
- **35 tools** across 7 categories. All tools use Microsoft Graph API or SharePoint REST API — no middlemen.
59
+ **30+ tools** across 7 categories. All tools use Microsoft Graph API or SharePoint REST API — no middlemen.
60
60
 
61
61
  ### Sites (4 tools)
62
62
  - `list_sites` — List all SharePoint sites in the tenant
@@ -65,8 +65,8 @@ Built for manufacturing companies managing factory documentation in SharePoint,
65
65
  - `create_site` — Create a new Communication or Team site
66
66
 
67
67
  ### Documents (9 tools)
68
- - `list_document_libraries` — List document libraries (drives)
69
- - `list_documents` — List documents with both driveItemId and listItemId
68
+ - `list_document_libraries` — List document libraries. Returns `driveId` (for file operations) and `listId` (for metadata operations) — call this first when working with documents.
69
+ - `list_documents` — List documents with both driveItemId and listItemId. Optional `fields: "minimal"` returns only id/name/isFolder/size (~74% token savings for exploration).
70
70
  - `upload_document` — Upload a file to SharePoint (auto session upload for files over 4 MB)
71
71
  - `upload_documents` — Upload multiple files with optional metadata in one call (max 30 per call)
72
72
  - `download_document` — Download files from SharePoint to a local path
@@ -75,25 +75,27 @@ Built for manufacturing companies managing factory documentation in SharePoint,
75
75
  - `create_folder` — Create folders
76
76
  - `get_document_versions` — Version history (audit trail)
77
77
 
78
- ### Metadata (5 tools)
78
+ ### Metadata (6 tools)
79
79
  - `list_columns` — List custom metadata columns
80
80
  - `create_choice_column` — Create choice/dropdown columns (single or multi-select)
81
81
  - `create_text_column` — Create text columns
82
+ - `delete_column` — Permanently delete a column from a list or document library (irreversible)
82
83
  - `get_document_metadata` — Read document metadata
83
84
  - `set_document_metadata` — Set metadata on documents
84
85
 
85
86
  ### Pages — Graph API (6 tools)
86
- - `list_pages` — List all pages
87
+ - `list_pages` — List all pages. Optional `includeItemId: true` also returns numeric item IDs needed by canvas tools (requires delegated auth and siteUrl).
87
88
  - `create_page` — Create empty page
88
89
  - `create_page_with_content` — Create page with sections and HTML content
89
90
  - `add_quick_links` — Add Quick Links web part
90
91
  - `publish_page` — Publish a draft page
91
92
  - `delete_page` — Delete a page
92
93
 
93
- ### Pages — SharePoint REST API (4 tools)
94
- - `list_site_pages` — List pages with numeric IDs
95
- - `get_page_canvas_content` — Read raw page content (CanvasContent1)
96
- - `set_page_canvas_content` — Write raw page content (supports Highlighted Content and any web part)
94
+ ### Pages — SharePoint REST API (5 tools)
95
+ - `get_page_canvas_summary` — Returns structured page overview (web part types, titles, filters, layout). ~400 B JSON vs ~15 KB raw HTML. Default tool for read-only canvas analysis.
96
+ - `get_page_canvas_content` — Read raw CanvasContent1 HTML. Use only when you need full HTML for structural edits via set_page_canvas_content.
97
+ - `set_page_canvas_content` — Write raw page content (structural edits: adding or removing web parts).
98
+ - `patch_page_canvas_webpart` — Surgically update web part properties (title, filter, layout, maxItems) without Claude handling the full canvas. Saves ~6 000 tokens per update vs get/set canvas pattern.
97
99
  - `copy_page` — Copy a page as template
98
100
 
99
101
  ### Navigation (3 tools)
@@ -102,7 +104,7 @@ Built for manufacturing companies managing factory documentation in SharePoint,
102
104
  - `delete_navigation_link` — Remove link from navigation
103
105
 
104
106
  ### Permissions (4 tools)
105
- - `get_permissions` — List SharePoint groups (Visitors, Members, Owners)
107
+ - `get_permissions` — List SharePoint groups (Visitors, Members, Owners). Optional `includeMembers: true` returns members inside each group in a single call.
106
108
  - `get_group_members` — List members of a group
107
109
  - `add_user_to_group` — Add user to a group
108
110
  - `remove_user_from_group` — Remove user from a group
@@ -229,7 +231,7 @@ Replace the four `your-*` values with what you collected in Step 8 of the Azure
229
231
 
230
232
  On Windows, use `C:\\Users\\YOUR_USERNAME\\.365center-mcp:/home/mcp/.365center-mcp` as the volume mount.
231
233
 
232
- **4. Open Claude Desktop.** The server will appear in the MCP menu with all 35 tools loaded.
234
+ **4. Open Claude Desktop.** The server will appear in the MCP menu with all 36 tools loaded.
233
235
 
234
236
  > **Why the volume mount?** The server caches delegated auth tokens in `~/.365center-mcp/token-cache.json`. Without the volume, you would need to re-authenticate every time Docker restarts.
235
237
 
@@ -474,7 +476,7 @@ The Graph API `create_page_with_content` tool supports these standard web parts:
474
476
  | Title Area | `cbe7b0a9-3504-44dd-a3a3-0e5cacd07788` |
475
477
  | YouTube Embed | `544dd15b-cf3c-441b-96da-004d5a8cea1d` |
476
478
 
477
- For **Highlighted Content** and any other web part not in this list, use the REST API tools (`get_page_canvas_content` and `set_page_canvas_content`) they can read and write any web part including Highlighted Content.
479
+ For **Highlighted Content** and any other web part not in this list, use `get_page_canvas_summary` to inspect, `patch_page_canvas_webpart` to update properties, and `get/set_page_canvas_content` for structural edits.
478
480
 
479
481
  ---
480
482
 
package/dist/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ClientSecretCredential } from "@azure/identity";
2
2
  import { Client } from "@microsoft/microsoft-graph-client";
3
3
  import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js";
4
+ import { Entry } from "@napi-rs/keyring";
4
5
  import dotenv from "dotenv";
5
6
  import path from "path";
6
7
  import { fileURLToPath } from "url";
@@ -20,29 +21,70 @@ export const graphClient = Client.initWithMiddleware({
20
21
  authProvider,
21
22
  });
22
23
  // ============ DELEGATED AUTH (SharePoint REST API) ============
23
- // Store token in user's home directory works for Node, Docker (with volume), and plugins
24
+ // Refresh token storage: OS keyring (macOS Keychain / Windows Credential Manager / Linux libsecret)
25
+ // with file fallback (chmod 600) for headless / Docker / keyring-less environments.
26
+ // Access token is kept in-memory only — never written to disk.
27
+ const KEYRING_SERVICE = "365center-mcp";
28
+ const KEYRING_ACCOUNT = "refresh-token";
24
29
  const TOKEN_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "/tmp", ".365center-mcp");
25
- if (!fs.existsSync(TOKEN_DIR))
26
- fs.mkdirSync(TOKEN_DIR, { recursive: true });
27
- const TOKEN_CACHE_PATH = path.join(TOKEN_DIR, "token-cache.json");
30
+ const TOKEN_FILE = path.join(TOKEN_DIR, "refresh-token");
28
31
  const SHAREPOINT_DOMAIN = process.env.SHAREPOINT_DOMAIN || "";
29
32
  const SP_SCOPES = `offline_access https://${SHAREPOINT_DOMAIN}/AllSites.FullControl`;
30
- function loadTokenCache() {
33
+ let cachedAccessToken = null;
34
+ function loadRefreshToken() {
31
35
  try {
32
- if (fs.existsSync(TOKEN_CACHE_PATH)) {
33
- return JSON.parse(fs.readFileSync(TOKEN_CACHE_PATH, "utf-8"));
36
+ const v = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword();
37
+ if (v)
38
+ return v;
39
+ }
40
+ catch { }
41
+ try {
42
+ if (fs.existsSync(TOKEN_FILE)) {
43
+ return fs.readFileSync(TOKEN_FILE, "utf-8").trim() || null;
34
44
  }
35
45
  }
36
46
  catch { }
37
47
  return null;
38
48
  }
39
- function saveTokenCache(cache) {
40
- fs.writeFileSync(TOKEN_CACHE_PATH, JSON.stringify(cache, null, 2));
49
+ function saveRefreshToken(token) {
50
+ try {
51
+ new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).setPassword(token);
52
+ return;
53
+ }
54
+ catch { }
55
+ try {
56
+ if (!fs.existsSync(TOKEN_DIR)) {
57
+ fs.mkdirSync(TOKEN_DIR, { recursive: true });
58
+ }
59
+ try {
60
+ fs.chmodSync(TOKEN_DIR, 0o700);
61
+ }
62
+ catch { }
63
+ fs.writeFileSync(TOKEN_FILE, token);
64
+ try {
65
+ fs.chmodSync(TOKEN_FILE, 0o600);
66
+ }
67
+ catch { }
68
+ }
69
+ catch { }
70
+ }
71
+ function clearRefreshToken() {
72
+ try {
73
+ new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).deletePassword();
74
+ }
75
+ catch { }
76
+ try {
77
+ if (fs.existsSync(TOKEN_FILE))
78
+ fs.unlinkSync(TOKEN_FILE);
79
+ }
80
+ catch { }
81
+ cachedAccessToken = null;
41
82
  }
42
83
  async function refreshAccessToken(refreshToken) {
84
+ // Device code flow uses a public client — client_secret must NOT be sent
85
+ // (Microsoft returns AADSTS700025 if included).
43
86
  const body = new URLSearchParams({
44
87
  client_id: clientId,
45
- client_secret: clientSecret,
46
88
  grant_type: "refresh_token",
47
89
  refresh_token: refreshToken,
48
90
  scope: SP_SCOPES,
@@ -53,13 +95,16 @@ async function refreshAccessToken(refreshToken) {
53
95
  throw new Error(`Token refresh failed: ${err}`);
54
96
  }
55
97
  const data = await response.json();
56
- const cache = {
57
- accessToken: data.access_token,
58
- refreshToken: data.refresh_token || refreshToken,
98
+ const accessToken = data.access_token;
99
+ const newRefreshToken = data.refresh_token || refreshToken;
100
+ cachedAccessToken = {
101
+ token: accessToken,
59
102
  expiresAt: Date.now() + data.expires_in * 1000,
60
103
  };
61
- saveTokenCache(cache);
62
- return cache;
104
+ if (newRefreshToken !== refreshToken) {
105
+ saveRefreshToken(newRefreshToken);
106
+ }
107
+ return accessToken;
63
108
  }
64
109
  // Device code polling — runs in background after user gets instructions
65
110
  let deviceCodePollingPromise = null;
@@ -80,14 +125,15 @@ function pollForDeviceCodeToken(deviceCode, interval, expiresIn) {
80
125
  });
81
126
  const tokenData = await tokenResponse.json();
82
127
  if (tokenData.access_token) {
83
- const cache = {
84
- accessToken: tokenData.access_token,
85
- refreshToken: tokenData.refresh_token,
128
+ cachedAccessToken = {
129
+ token: tokenData.access_token,
86
130
  expiresAt: Date.now() + tokenData.expires_in * 1000,
87
131
  };
88
- saveTokenCache(cache);
132
+ if (tokenData.refresh_token) {
133
+ saveRefreshToken(tokenData.refresh_token);
134
+ }
89
135
  deviceCodePollingPromise = null;
90
- resolve(cache);
136
+ resolve(tokenData.access_token);
91
137
  return;
92
138
  }
93
139
  if (tokenData.error === "authorization_pending")
@@ -123,7 +169,9 @@ async function startDeviceCodeFlow() {
123
169
  }
124
170
  const codeData = await codeResponse.json();
125
171
  const { device_code, user_code, verification_uri, expires_in, interval, message } = codeData;
126
- // Start polling in background
172
+ // Start polling in background. Clear any stale refresh token so a stuck/invalid
173
+ // one from a previous run doesn't race the device code flow.
174
+ clearRefreshToken();
127
175
  deviceCodePollingPromise = pollForDeviceCodeToken(device_code, interval, expires_in);
128
176
  // Throw error with login instructions — Claude sees this and tells the user
129
177
  throw new Error(`LOGIN REQUIRED: ${message}\n\n` +
@@ -132,26 +180,25 @@ async function startDeviceCodeFlow() {
132
180
  `After logging in, try your request again.`);
133
181
  }
134
182
  export async function getDelegatedToken() {
135
- // 1. Check cache
136
- const cache = loadTokenCache();
137
- if (cache) {
138
- if (cache.expiresAt > Date.now() + 300000) {
139
- return cache.accessToken;
140
- }
183
+ // 1. In-memory access token still valid?
184
+ if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 300000) {
185
+ return cachedAccessToken.token;
186
+ }
187
+ // 2. Try refresh token from keyring/file
188
+ const refreshToken = loadRefreshToken();
189
+ if (refreshToken) {
141
190
  try {
142
- const refreshed = await refreshAccessToken(cache.refreshToken);
143
- return refreshed.accessToken;
191
+ return await refreshAccessToken(refreshToken);
144
192
  }
145
193
  catch {
146
- // Refresh failed — need new login
194
+ // Refresh failed (expired/revoked) fall through to device code flow
147
195
  }
148
196
  }
149
- // 2. If polling is already running, wait for it
197
+ // 3. If polling is already running, wait for it
150
198
  if (deviceCodePollingPromise) {
151
- const result = await deviceCodePollingPromise;
152
- return result.accessToken;
199
+ return await deviceCodePollingPromise;
153
200
  }
154
- // 3. No cache, no polling — start device code flow (throws with instructions)
201
+ // 4. No cache, no polling — start device code flow (throws with instructions)
155
202
  await startDeviceCodeFlow();
156
203
  throw new Error("unreachable"); // startDeviceCodeFlow always throws
157
204
  }
package/dist/index.js CHANGED
@@ -7,10 +7,10 @@ import { fileURLToPath } from "url";
7
7
  import path from "path";
8
8
  import { listSites, getSite, getSiteById } from "./tools/sites.js";
9
9
  import { listDocumentLibraries, listDocuments, uploadDocument, uploadDocuments, downloadDocument, searchDocuments, deleteDocument, createFolder, getDocumentVersions } from "./tools/documents.js";
10
- import { listColumns, createChoiceColumn, createTextColumn, setDocumentMetadata, getDocumentMetadata } from "./tools/metadata.js";
10
+ import { listColumns, createChoiceColumn, createTextColumn, deleteColumn, setDocumentMetadata, getDocumentMetadata } from "./tools/metadata.js";
11
11
  import { listPages, createPage, createPageWithContent, addQuickLinksWebPart, publishPage, deletePage } from "./tools/pages.js";
12
12
  import { getNavigation, addNavigationLink, deleteNavigationLink } from "./tools/navigation.js";
13
- import { getPageCanvasContent, setPageCanvasContent, copyPage, listSitePages } from "./tools/pages-rest.js";
13
+ import { getPageCanvasContent, getPageCanvasSummary, setPageCanvasContent, copyPage, patchPageCanvasWebpart } from "./tools/pages-rest.js";
14
14
  import { createSite } from "./tools/sites-rest.js";
15
15
  import { getSitePermissions, getGroupMembers, addUserToGroup, removeUserFromGroup } from "./tools/permissions.js";
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,16 +41,17 @@ server.tool("create_site", "Create a new SharePoint site. Template: 'communicati
41
41
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
42
42
  });
43
43
  // ============ DOCUMENTS ============
44
- server.tool("list_document_libraries", "List all document libraries (drives) in a SharePoint site. Returns driveId and listId for each library. Use driveId for file operations (upload, download, delete, list, versions). Use listId for metadata operations (list_columns, get/set_document_metadata, create columns). Call this first when working with documents — you need both IDs.", { siteId: z.string().describe("SharePoint site ID") }, async ({ siteId }) => {
44
+ server.tool("list_document_libraries", "List all document libraries in a SharePoint site. Returns driveId, listId, name, and URL for each library. Use driveId for file operations (upload, download, delete, list, versions). Use listId for metadata operations (list_columns, get/set_document_metadata, create columns). Call this first when working with documents — you need both IDs.", { siteId: z.string().describe("SharePoint site ID") }, async ({ siteId }) => {
45
45
  const libraries = await listDocumentLibraries(siteId);
46
46
  return { content: [{ type: "text", text: JSON.stringify(libraries, null, 2) }] };
47
47
  });
48
- server.tool("list_documents", "List documents in a document library folder. Returns both driveItemId and listItemId for each document. Use driveItemId (+ driveId) for file operations (download, delete, versions). Use listItemId (or driveItemId + driveId) for metadata operations (get/set_document_metadata). The listId parameter for metadata tools can be the list display name (e.g. 'Dokumenty') or the list GUID.", {
48
+ server.tool("list_documents", "List documents in a document library folder. Returns both driveItemId and listItemId for each document. Use driveItemId (+ driveId) for file operations (download, delete, versions). Use listItemId (or driveItemId + driveId) for metadata operations (get/set_document_metadata). The listId parameter for metadata tools can be the list display name (e.g. 'Dokumenty') or the list GUID. For exploration or listing large folders, set fields: \"minimal\" (~74% token savings, returns id/name/isFolder/size only). Use fields: \"all\" (default) when you need URL, mimeType, dates, or metadata.", {
49
49
  siteId: z.string().describe("SharePoint site ID"),
50
50
  driveId: z.string().describe("Document library (drive) ID"),
51
51
  folderId: z.string().optional().describe("Folder ID (default: root)"),
52
- }, async ({ siteId, driveId, folderId }) => {
53
- const docs = await listDocuments(siteId, driveId, folderId || "root");
52
+ fields: z.enum(["all", "minimal"]).optional().describe("Field set: 'all' (default, full metadata) or 'minimal' (id, name, isFolder, size only — saves ~74% tokens for exploration)"),
53
+ }, async ({ siteId, driveId, folderId, fields }) => {
54
+ const docs = await listDocuments(siteId, driveId, folderId || "root", fields || "all");
54
55
  return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
55
56
  });
56
57
  server.tool("upload_document", "Upload a single local file to a SharePoint document library. Root folder by default, or specific folder via folderId. After upload, use set_document_metadata to tag it. Supports up to 250 GB (auto session upload for >4 MB). For multiple files, prefer upload_documents — up to 30 files per call with inline metadata and built-in rate limit protection. NOTE: If using Highlighted Content web parts, filename prefix determines filtering. The 'Title includes the words' filter is SUBSTRING-based — e.g. 'Report' will also match 'ReportQ1' or 'ReportAnnual'. Use non-overlapping prefixes.", {
@@ -124,14 +125,14 @@ server.tool("get_document_versions", "Get version history of a document (audit t
124
125
  // ============ METADATA ============
125
126
  server.tool("list_columns", "List custom metadata columns in a SharePoint list/library. listId can be display name (e.g. 'Documents') or GUID. Returns internal name, display name, type, and choices. Call this before set_document_metadata — internal column names (used in API) often differ from display names (shown in UI). Using wrong name fails silently.", {
126
127
  siteId: z.string().describe("SharePoint site ID"),
127
- listId: z.string().describe("List or document library list ID"),
128
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
128
129
  }, async ({ siteId, listId }) => {
129
130
  const columns = await listColumns(siteId, listId);
130
131
  return { content: [{ type: "text", text: JSON.stringify(columns, null, 2) }] };
131
132
  });
132
133
  server.tool("create_choice_column", "Create a choice/dropdown metadata column in a SharePoint list or document library. Use allowMultiple:true for multi-select checkboxes (e.g. document belongs to multiple areas). Column 'name' is the internal API name (no spaces/special chars), 'displayName' is what users see in SharePoint UI. The column must be created BEFORE setting metadata values on documents.", {
133
134
  siteId: z.string().describe("SharePoint site ID"),
134
- listId: z.string().describe("List or document library list ID"),
135
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
135
136
  name: z.string().describe("Internal column name"),
136
137
  displayName: z.string().describe("Display name shown in UI"),
137
138
  choices: z.array(z.string()).describe("List of choices"),
@@ -142,16 +143,24 @@ server.tool("create_choice_column", "Create a choice/dropdown metadata column in
142
143
  });
143
144
  server.tool("create_text_column", "Create a single-line text metadata column in a SharePoint list or document library. Column 'name' is the internal API name (no spaces/special chars), 'displayName' is what users see in SharePoint UI. The column must be created BEFORE setting metadata values on documents.", {
144
145
  siteId: z.string().describe("SharePoint site ID"),
145
- listId: z.string().describe("List or document library list ID"),
146
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
146
147
  name: z.string().describe("Internal column name"),
147
148
  displayName: z.string().describe("Display name shown in UI"),
148
149
  }, async ({ siteId, listId, name, displayName }) => {
149
150
  const result = await createTextColumn(siteId, listId, name, displayName);
150
151
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
151
152
  });
153
+ server.tool("delete_column", "PERMANENTLY delete a column from a SharePoint list or document library. This action is irreversible — the column definition and all associated metadata values on items are removed. Always confirm with the user before deleting. Use list_columns to find the columnId.", {
154
+ siteId: z.string().describe("SharePoint site ID"),
155
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
156
+ columnId: z.string().describe("Column GUID (from list_columns)"),
157
+ }, async ({ siteId, listId, columnId }) => {
158
+ const result = await deleteColumn(siteId, listId, columnId);
159
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
160
+ });
152
161
  server.tool("set_document_metadata", "Set metadata fields on a document. 'fields' is a JSON string of key-value pairs using column internal names. Choice values must match predefined choices exactly (case-sensitive). Multi-select: use array of strings. Columns must exist first — check with list_columns. Accepts drive item ID (requires driveId) or numeric list item ID. Only change fields the user asked for — leave others untouched. TIP: Changing a Status field (e.g. to 'Pending Approval') can trigger Power Automate flows with 'When item modified' trigger — no HTTP webhooks or extra tools needed.", {
153
162
  siteId: z.string().describe("SharePoint site ID"),
154
- listId: z.string().describe("List or document library list ID"),
163
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
155
164
  itemId: z.string().describe("Document ID — either numeric list item ID or drive item ID"),
156
165
  fields: z.string().describe("JSON string of key-value pairs, e.g. {\"Oblast\":\"Linka 1\",\"Status\":\"Platný\"}"),
157
166
  driveId: z.string().optional().describe("Drive ID — required when itemId is a drive item ID (non-numeric)"),
@@ -162,7 +171,7 @@ server.tool("set_document_metadata", "Set metadata fields on a document. 'fields
162
171
  });
163
172
  server.tool("get_document_metadata", "Get all metadata fields of a document including custom columns. Accepts both drive item ID and numeric list item ID — if using drive item ID, provide driveId. Returns all field values including system fields and custom metadata.", {
164
173
  siteId: z.string().describe("SharePoint site ID"),
165
- listId: z.string().describe("List or document library list ID"),
174
+ listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
166
175
  itemId: z.string().describe("Document ID — either numeric list item ID or drive item ID"),
167
176
  driveId: z.string().optional().describe("Drive ID — required when itemId is a drive item ID (non-numeric)"),
168
177
  }, async ({ siteId, listId, itemId, driveId }) => {
@@ -170,16 +179,21 @@ server.tool("get_document_metadata", "Get all metadata fields of a document incl
170
179
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
171
180
  });
172
181
  // ============ PAGES ============
173
- server.tool("list_pages", "List all pages via Graph API. Returns page GUID, name, title, URL, and state. Use page GUID for publish_page, delete_page, add_quick_links. NOTE: Canvas tools (get/set_page_canvas_content) need NUMERIC item IDs from list_site_pages instead GUIDs from this tool won't work there.", { siteId: z.string().describe("SharePoint site ID") }, async ({ siteId }) => {
174
- const pages = await listPages(siteId);
182
+ server.tool("list_pages", "List all pages via Graph API (app-only auth). Returns page GUID (id), name, title, URL, and publishing state. Use page GUID for publish_page, delete_page, add_quick_links. To include numeric itemId (needed for canvas tools: get_page_canvas_content, set_page_canvas_content, get_page_canvas_summary, patch_page_canvas_webpart), set includeItemId: true requires delegated auth (one-time device code login) and siteUrl. Without includeItemId, this tool works app-only with no login flow.", {
183
+ siteId: z.string().describe("SharePoint site ID"),
184
+ siteUrl: z.string().optional().describe("Full SharePoint site URL (required when includeItemId is true)"),
185
+ includeItemId: z.boolean().optional().describe("If true, also fetch numeric itemId for each page (requires delegated auth and siteUrl)"),
186
+ }, async ({ siteId, siteUrl, includeItemId }) => {
187
+ const pages = await listPages(siteId, siteUrl, includeItemId || false);
175
188
  return { content: [{ type: "text", text: JSON.stringify(pages, null, 2) }] };
176
189
  });
177
190
  server.tool("create_page", "Create a new empty page in checkout/draft state. MUST call publish_page after to make it visible. 'name' becomes URL slug (e.g. 'my-page' → my-page.aspx). For pages with text sections, use create_page_with_content. For pages with web parts (Highlighted Content etc.), create empty page first, then set_page_canvas_content, then publish_page.", {
178
191
  siteId: z.string().describe("SharePoint site ID"),
179
192
  title: z.string().describe("Page title"),
180
193
  name: z.string().describe("Page file name (without .aspx)"),
181
- }, async ({ siteId, title, name }) => {
182
- const result = await createPage(siteId, title, name);
194
+ pageLayout: z.enum(["article", "home"]).optional().describe("Page layout. 'article' (default) — standard content page with title banner (image, title, author, date) above the canvas. Use for 99% of pages. 'home' — dashboard-style layout without title banner, canvas goes full-width from the top. Use only when you explicitly want a landing-page feel. Note: 'home' does NOT set the page as the site's actual home page — that's a separate site setting."),
195
+ }, async ({ siteId, title, name, pageLayout }) => {
196
+ const result = await createPage(siteId, title, name, pageLayout);
183
197
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
184
198
  });
185
199
  server.tool("create_page_with_content", "Create a page with HTML text sections. Draft state — MUST call publish_page after. Sections is a JSON string array. Layouts: oneColumn (12), twoColumns (6+6), threeColumns (4+4+4), oneThirdLeftColumn (4+8), oneThirdRightColumn (8+4), fullWidth (12). Example: [{\"layout\":\"twoColumns\",\"columns\":[{\"width\":6,\"html\":\"<h2>Left</h2>\"},{\"width\":6,\"html\":\"<h2>Right</h2>\"}]}]. NOTE: Only supports text/HTML. For web parts like Highlighted Content, use create_page + set_page_canvas_content instead.", {
@@ -240,8 +254,11 @@ server.tool("delete_navigation_link", "Remove a link from the top navigation men
240
254
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
241
255
  });
242
256
  // ============ PERMISSIONS ============
243
- server.tool("get_permissions", "Get all SharePoint groups (Visitors, Members, Owners, custom) for a site. Returns group ID, title, and description. Use group ID with get_group_members to see who is in each group, or with add_user_to_group/remove_user_from_group to manage membership. Uses SharePoint REST API with delegated auth.", { siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)") }, async ({ siteUrl }) => {
244
- const result = await getSitePermissions(siteUrl);
257
+ server.tool("get_permissions", "Get all SharePoint groups (Visitors, Members, Owners, custom) for a site. Returns group ID, title, description, and owner title. With includeMembers: true, also returns all users in each group in a single call (id, title, email, loginName) avoids calling get_group_members separately for each group. Uses SharePoint REST API with delegated auth.", {
258
+ siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)"),
259
+ includeMembers: z.boolean().optional().describe("If true, include member list (id, title, email, loginName) inside each group — single call for full permission overview. If false/omitted, returns groups only."),
260
+ }, async ({ siteUrl, includeMembers }) => {
261
+ const result = await getSitePermissions(siteUrl, includeMembers);
245
262
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
246
263
  });
247
264
  server.tool("get_group_members", "Get all members of a SharePoint group. Use get_permissions first to find the groupId. Returns user ID, name, email, and login name for each member.", {
@@ -268,21 +285,43 @@ server.tool("remove_user_from_group", "Remove a user from a SharePoint group. Us
268
285
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
269
286
  });
270
287
  // ============ REST API — CANVAS CONTENT ============
271
- server.tool("get_page_canvas_content", "Read raw CanvasContent1 of a page via REST API. Returns full HTML including all web parts. Use to inspect existing page structure before modifying. pageItemId = numeric ID from list_site_pages (NOT GUID from list_pages). Uses delegated auth. ALWAYS read before calling set_page_canvas_content — understand what's there before replacing it.", {
288
+ server.tool("get_page_canvas_content", "Read RAW CanvasContent1 HTML (~3 000 tokens per 5-WS page, 97.5% boilerplate). ⚠️ ONLY use when you need full HTML string to pass to set_page_canvas_content (structural edits: adding or removing web parts). For read-only questions (\"what's on this page\", audit, exploration), ALWAYS use get_page_canvas_summary instead — it returns structured JSON ~30× smaller. pageItemId = numeric ID from list_pages (call with includeItemId: true). Uses delegated auth.", {
272
289
  siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)"),
273
- pageItemId: z.number().describe("Numeric item ID from Site Pages list (use list_site_pages to find it)"),
290
+ pageItemId: z.number().describe("Numeric item ID from list_pages (call with includeItemId: true to get it)"),
274
291
  }, async ({ siteUrl, pageItemId }) => {
275
292
  const result = await getPageCanvasContent(siteUrl, pageItemId);
276
293
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
277
294
  });
278
- server.tool("set_page_canvas_content", "Replace entire page canvas via REST API. Overwrites ALL existing content. Uses delegated auth. pageItemId = numeric ID from list_site_pages. MUST call publish_page after page reverts to draft and appears EMPTY without it. Canvas HTML rules: use pre-encoded entities directly (&#123; &#125; &#58; &quot;) never JSON.stringify then replace (double-escaping on file round-trips). Omit titleHTML property (causes escaping corruption) titles render from searchablePlainTexts. Read current canvas with get_page_canvas_content first.", {
295
+ server.tool("get_page_canvas_summary", "Returns structured page overview: web part types, titles, filter values, section layout. ~400 B JSON vs ~15 KB raw HTML for get_page_canvas_content. USE THIS for: exploration, audit, \"what's on this page\", planning edits, reading web part titles/filters. Recognizes HighlightedContent and QuickLinks web parts; unknown web part types return as type \"Unknown\" with webPartId. Do NOT use get_page_canvas_content unless you need the full HTML for set_page_canvas_content (structural edits). pageItemId = numeric ID from list_pages (call with includeItemId: true). Uses delegated auth.", {
296
+ siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)"),
297
+ pageItemId: z.number().describe("Numeric item ID from list_pages (call with includeItemId: true to get it)"),
298
+ }, async ({ siteUrl, pageItemId }) => {
299
+ const result = await getPageCanvasSummary(siteUrl, pageItemId);
300
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
301
+ });
302
+ server.tool("set_page_canvas_content", "Replace entire page canvas via REST API. Overwrites ALL existing content. Uses delegated auth. pageItemId = numeric ID from list_pages (call with includeItemId: true). MUST call publish_page after — page reverts to draft and appears EMPTY without it. Canvas HTML rules: use pre-encoded entities directly (&#123; &#125; &#58; &quot;) — never JSON.stringify then replace (double-escaping on file round-trips). Omit titleHTML property (causes escaping corruption) — titles render from searchablePlainTexts. Read current canvas with get_page_canvas_content first.", {
279
303
  siteUrl: z.string().describe("Full SharePoint site URL"),
280
- pageItemId: z.number().describe("Numeric item ID from Site Pages list"),
304
+ pageItemId: z.number().describe("Numeric item ID from list_pages (call with includeItemId: true to get it)"),
281
305
  canvasContent: z.string().describe("Raw HTML/JSON canvas content string — get format from get_page_canvas_content on an existing page"),
282
306
  }, async ({ siteUrl, pageItemId, canvasContent }) => {
283
307
  const result = await setPageCanvasContent(siteUrl, pageItemId, canvasContent);
284
308
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
285
309
  });
310
+ server.tool("patch_page_canvas_webpart", "Surgically update web part properties (title, filter, layout, maxItems) on a page without Claude handling the full canvas. MCP handles read-modify-write internally — Claude never sees the full canvas HTML. ✅ USE THIS for any title/filter/layout change on existing web parts. Saves ~6 000 tokens vs get_page_canvas_content + set_page_canvas_content pattern. Use set_page_canvas_content only for structural changes (adding/removing web parts). After patching, call publish_page to make changes visible.", {
311
+ siteUrl: z.string().describe("Full SharePoint site URL"),
312
+ pageItemId: z.number().describe("Numeric item ID from list_pages (call with includeItemId: true to get it)"),
313
+ patches: z.array(z.object({
314
+ instanceId: z.string().describe("Web part instanceId from get_page_canvas_summary (e.g. 'ws1-l1-inst')"),
315
+ title: z.string().optional().describe("New title — updates searchablePlainTexts.title in webpartdata AND the data-sp-htmlproperties div"),
316
+ filter: z.string().optional().describe("New filter value — HighlightedContent only (query.filters[0].value)"),
317
+ maxItems: z.number().optional().describe("New maxItemsPerPage — HighlightedContent only"),
318
+ layout: z.string().optional().describe("New layoutId (e.g. 'CompactGrid', 'FilmStrip')"),
319
+ })).describe("Array of patches. Each patch targets one web part by instanceId and changes one or more properties."),
320
+ verify: z.boolean().optional().describe("After write, read back and verify new values are present (default: true). Set false to skip verification for speed."),
321
+ }, async ({ siteUrl, pageItemId, patches, verify }) => {
322
+ const result = await patchPageCanvasWebpart(siteUrl, pageItemId, patches, verify !== false);
323
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
324
+ });
286
325
  server.tool("copy_page", "Copy an existing SharePoint page to create a new one. Useful for using template pages — create one page with the desired layout in SharePoint editor, then copy it programmatically. Both source and target are file names in SitePages folder (e.g. 'template.aspx', 'new-page.aspx'). Uses delegated auth.", {
287
326
  siteUrl: z.string().describe("Full SharePoint site URL"),
288
327
  sourceFileName: z.string().describe("Source page file name (e.g. 'template.aspx')"),
@@ -291,12 +330,6 @@ server.tool("copy_page", "Copy an existing SharePoint page to create a new one.
291
330
  const result = await copyPage(siteUrl, sourceFileName, targetFileName);
292
331
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
293
332
  });
294
- server.tool("list_site_pages", "List pages via REST API. Returns NUMERIC item IDs needed for canvas tools (get/set_page_canvas_content). Also returns title and file name. Uses delegated auth. NOTE: This gives numeric IDs for canvas operations. For page GUIDs (needed by publish_page, delete_page), use list_pages instead.", {
295
- siteUrl: z.string().describe("Full SharePoint site URL"),
296
- }, async ({ siteUrl }) => {
297
- const result = await listSitePages(siteUrl);
298
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
299
- });
300
333
  // ============ START SERVER ============
301
334
  async function main() {
302
335
  const transport = new StdioServerTransport();
@@ -1,5 +1,5 @@
1
1
  export declare function listDocumentLibraries(siteId: string): Promise<any>;
2
- export declare function listDocuments(siteId: string, driveId: string, folderId?: string): Promise<any>;
2
+ export declare function listDocuments(siteId: string, driveId: string, folderId?: string, fields?: "all" | "minimal"): Promise<any>;
3
3
  export declare function uploadDocument(siteId: string, driveId: string, fileName: string, filePath: string, folderId?: string): Promise<{
4
4
  id: any;
5
5
  name: any;
@@ -4,20 +4,30 @@ import fs from "fs";
4
4
  import path from "path";
5
5
  import { Readable } from "stream";
6
6
  export async function listDocumentLibraries(siteId) {
7
+ // Single call with $expand=list($select=id) gives us both driveId (for file ops)
8
+ // and listId (for metadata ops) — drive.list is the associated SharePoint list.
7
9
  const result = await graphClient
8
- .api(`/sites/${siteId}/drives`)
10
+ .api(`/sites/${siteId}/drives?$expand=list($select=id)`)
9
11
  .get();
10
12
  return result.value.map((drive) => ({
11
- id: drive.id,
13
+ driveId: drive.id,
14
+ listId: drive.list?.id,
12
15
  name: drive.name,
13
16
  url: drive.webUrl,
14
- itemCount: drive.quota?.used,
15
17
  }));
16
18
  }
17
- export async function listDocuments(siteId, driveId, folderId = "root") {
19
+ export async function listDocuments(siteId, driveId, folderId = "root", fields = "all") {
18
20
  const result = await graphClient
19
21
  .api(`/sites/${siteId}/drives/${driveId}/items/${folderId}/children?$expand=listItem`)
20
22
  .get();
23
+ if (fields === "minimal") {
24
+ return result.value.map((item) => ({
25
+ id: item.id,
26
+ name: item.name,
27
+ isFolder: !!item.folder,
28
+ size: item.size,
29
+ }));
30
+ }
21
31
  return result.value.map((item) => ({
22
32
  id: item.id,
23
33
  listItemId: item.listItem?.id,
@@ -173,7 +183,8 @@ export async function getDocumentVersions(siteId, driveId, itemId) {
173
183
  }
174
184
  export async function uploadDocuments(siteId, driveId, listId, files, folderId = "root") {
175
185
  const results = [];
176
- for (const file of files) {
186
+ for (let i = 0; i < files.length; i++) {
187
+ const file = files[i];
177
188
  const entry = { fileName: file.fileName, status: "ok" };
178
189
  try {
179
190
  const uploaded = await uploadDocument(siteId, driveId, file.fileName, file.filePath, folderId);
@@ -199,7 +210,9 @@ export async function uploadDocuments(siteId, driveId, listId, files, folderId =
199
210
  entry.metadataStatus = "skipped";
200
211
  }
201
212
  results.push(entry);
202
- await new Promise((r) => setTimeout(r, 500));
213
+ if (i < files.length - 1) {
214
+ await new Promise((r) => setTimeout(r, 500));
215
+ }
203
216
  }
204
217
  return results;
205
218
  }
@@ -5,6 +5,10 @@ export declare function createChoiceColumn(siteId: string, listId: string, name:
5
5
  displayName: any;
6
6
  allowMultiple: boolean;
7
7
  }>;
8
+ export declare function deleteColumn(siteId: string, listId: string, columnId: string): Promise<{
9
+ success: boolean;
10
+ columnId: string;
11
+ }>;
8
12
  export declare function createTextColumn(siteId: string, listId: string, name: string, displayName: string): Promise<{
9
13
  id: any;
10
14
  name: any;
@@ -61,7 +61,10 @@ export async function createChoiceColumn(siteId, listId, name, displayName, choi
61
61
  }
62
62
  const site = await graphClient.api(`/sites/${siteId}`).select("webUrl").get();
63
63
  const siteUrl = site.webUrl;
64
- const result = await callSharePointRest(siteUrl, `/_api/web/lists/getByTitle('${listId}')/fields`, "POST", {
64
+ const listIdentifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(listId)
65
+ ? `lists(guid'${listId}')`
66
+ : `lists/getByTitle('${listId}')`;
67
+ const result = await callSharePointRest(siteUrl, `/_api/web/${listIdentifier}/fields`, "POST", {
65
68
  __metadata: { type: "SP.FieldMultiChoice" },
66
69
  FieldTypeKind: 15,
67
70
  Title: displayName,
@@ -76,6 +79,12 @@ export async function createChoiceColumn(siteId, listId, name, displayName, choi
76
79
  allowMultiple: true,
77
80
  };
78
81
  }
82
+ export async function deleteColumn(siteId, listId, columnId) {
83
+ await graphClient
84
+ .api(`/sites/${siteId}/lists/${listId}/columns/${columnId}`)
85
+ .delete();
86
+ return { success: true, columnId };
87
+ }
79
88
  export async function createTextColumn(siteId, listId, name, displayName) {
80
89
  const result = await graphClient
81
90
  .api(`/sites/${siteId}/lists/${listId}/columns`)
@@ -4,6 +4,51 @@ export declare function getPageCanvasContent(siteUrl: string, pageItemId: number
4
4
  fileName: any;
5
5
  canvasContent: any;
6
6
  }>;
7
+ type HighlightedContentSummary = {
8
+ instanceId: string;
9
+ type: "HighlightedContent";
10
+ zoneIndex: number;
11
+ sectionIndex: number;
12
+ title: string;
13
+ filter: string;
14
+ layout: string;
15
+ maxItems: number;
16
+ };
17
+ type QuickLinksSummary = {
18
+ instanceId: string;
19
+ type: "QuickLinks";
20
+ zoneIndex: number;
21
+ sectionIndex: number;
22
+ layout: string;
23
+ links: Array<{
24
+ title: string;
25
+ description: string;
26
+ url: string;
27
+ }>;
28
+ };
29
+ type UnknownWebPartSummary = {
30
+ instanceId: string;
31
+ type: "Unknown";
32
+ webPartId: string;
33
+ zoneIndex: number;
34
+ sectionIndex: number;
35
+ };
36
+ type WebPartSummary = HighlightedContentSummary | QuickLinksSummary | UnknownWebPartSummary;
37
+ type TextSection = {
38
+ zoneIndex: number;
39
+ sectionIndex: number;
40
+ sectionFactor?: number;
41
+ zoneEmphasis?: number;
42
+ heading: string | null;
43
+ content: string;
44
+ };
45
+ export declare function getPageCanvasSummary(siteUrl: string, pageItemId: number): Promise<{
46
+ id: any;
47
+ title: any;
48
+ fileName: any;
49
+ webParts: WebPartSummary[];
50
+ textSections: TextSection[];
51
+ }>;
7
52
  export declare function setPageCanvasContent(siteUrl: string, pageItemId: number, canvasContent: string): Promise<{
8
53
  success: boolean;
9
54
  pageItemId: number;
@@ -13,8 +58,20 @@ export declare function copyPage(siteUrl: string, sourceFileName: string, target
13
58
  source: string;
14
59
  target: string;
15
60
  }>;
16
- export declare function listSitePages(siteUrl: string): Promise<{
17
- itemId: any;
18
- title: any;
19
- fileName: any;
20
- }[]>;
61
+ export type WebPartPatch = {
62
+ instanceId: string;
63
+ title?: string;
64
+ filter?: string;
65
+ maxItems?: number;
66
+ layout?: string;
67
+ };
68
+ export type PatchWebPartResult = {
69
+ success: boolean;
70
+ pageItemId: number;
71
+ patched: string[];
72
+ notFound: string[];
73
+ verifyFailed?: string[];
74
+ };
75
+ export declare function patchPageCanvasWebpart(siteUrl: string, pageItemId: number, patches: WebPartPatch[], verify?: boolean): Promise<PatchWebPartResult>;
76
+ export declare function fetchSitePageItemIdMap(siteUrl: string): Promise<Map<string, number>>;
77
+ export {};
@@ -17,6 +17,156 @@ export async function getPageCanvasContent(siteUrl, pageItemId) {
17
17
  canvasContent: result.d.CanvasContent1,
18
18
  };
19
19
  }
20
+ // Known SharePoint web part IDs
21
+ const KNOWN_WEB_PARTS = {
22
+ "daf0b71c-6de8-4ef7-b511-faae7c388708": "HighlightedContent",
23
+ "c70391ea-0b10-4ee9-b2b4-006d3fcad0cd": "QuickLinks",
24
+ };
25
+ function decodeHtmlEntities(str) {
26
+ return str
27
+ .replace(/&quot;/g, '"')
28
+ .replace(/&#123;/g, "{")
29
+ .replace(/&#125;/g, "}")
30
+ .replace(/&#58;/g, ":")
31
+ .replace(/&#91;/g, "[")
32
+ .replace(/&#93;/g, "]")
33
+ .replace(/&amp;/g, "&")
34
+ .replace(/&lt;/g, "<")
35
+ .replace(/&gt;/g, ">")
36
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
37
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
38
+ }
39
+ function stripHtmlTags(html) {
40
+ return html.replace(/<[^>]+>/g, "").trim();
41
+ }
42
+ export async function getPageCanvasSummary(siteUrl, pageItemId) {
43
+ const listId = await getSitePagesListId(siteUrl);
44
+ const result = await callSharePointRest(siteUrl, `/_api/web/lists(guid'${listId}')/items(${pageItemId})?$select=Id,Title,FileLeafRef,CanvasContent1`, "GET");
45
+ const rawCanvas = result.d.CanvasContent1 || "";
46
+ // Extract all data-sp-controldata attribute values (encoded JSON)
47
+ const controlMatches = Array.from(rawCanvas.matchAll(/data-sp-controldata="([^"]*)"/g));
48
+ // Extract all data-sp-webpartdata attribute values (encoded JSON) in order
49
+ const webpartMatches = Array.from(rawCanvas.matchAll(/data-sp-webpartdata="([^"]*)"/g));
50
+ // Extract all RTE blocks (raw HTML inside data-sp-rte divs) in order
51
+ const rteMatches = Array.from(rawCanvas.matchAll(/data-sp-rte="">([\s\S]*?)<\/div>/g));
52
+ const webParts = [];
53
+ const textSections = [];
54
+ let wpIdx = 0;
55
+ let rteIdx = 0;
56
+ for (const ctrlMatch of controlMatches) {
57
+ const encodedControl = ctrlMatch[1];
58
+ if (!encodedControl)
59
+ continue;
60
+ let ctrl;
61
+ try {
62
+ ctrl = JSON.parse(decodeHtmlEntities(encodedControl));
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ const controlType = ctrl.controlType;
68
+ const position = ctrl.position || {};
69
+ const zoneIndex = position.zoneIndex ?? 0;
70
+ const sectionIndex = position.sectionIndex ?? 1;
71
+ const sectionFactor = position.sectionFactor;
72
+ const emphasis = ctrl.emphasis || {};
73
+ const zoneEmphasis = emphasis.zoneEmphasis;
74
+ // Skip system controls: 0=pageSettingsSlice, 1=empty section, 14=background
75
+ if (controlType === 0 || controlType === 1 || controlType === 14) {
76
+ continue;
77
+ }
78
+ if (controlType === 4) {
79
+ // RTE text block
80
+ const rteMatch = rteMatches[rteIdx++];
81
+ if (!rteMatch)
82
+ continue;
83
+ const rteHtml = rteMatch[1];
84
+ const headingMatch = rteHtml.match(/<h\d[^>]*>([\s\S]*?)<\/h\d>/);
85
+ const paragraphMatch = rteHtml.match(/<p[^>]*>([\s\S]*?)<\/p>/);
86
+ const section = {
87
+ zoneIndex,
88
+ sectionIndex,
89
+ heading: headingMatch ? stripHtmlTags(headingMatch[1]) : null,
90
+ content: paragraphMatch ? stripHtmlTags(paragraphMatch[1]) : "",
91
+ };
92
+ if (sectionFactor !== undefined)
93
+ section.sectionFactor = sectionFactor;
94
+ if (zoneEmphasis !== undefined)
95
+ section.zoneEmphasis = zoneEmphasis;
96
+ textSections.push(section);
97
+ }
98
+ else if (controlType === 3) {
99
+ // Web part
100
+ const wpMatch = webpartMatches[wpIdx++];
101
+ if (!wpMatch)
102
+ continue;
103
+ const encodedWp = wpMatch[1];
104
+ let wp;
105
+ try {
106
+ wp = JSON.parse(decodeHtmlEntities(encodedWp));
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ const webPartId = wp.id || "";
112
+ const type = KNOWN_WEB_PARTS[webPartId] || "Unknown";
113
+ const instanceId = wp.instanceId || "";
114
+ const serverContent = wp.serverProcessedContent || {};
115
+ const searchablePlainTexts = serverContent.searchablePlainTexts || {};
116
+ const properties = wp.properties || {};
117
+ if (type === "HighlightedContent") {
118
+ const filters = properties.query?.filters || [];
119
+ webParts.push({
120
+ instanceId,
121
+ type: "HighlightedContent",
122
+ zoneIndex,
123
+ sectionIndex,
124
+ title: searchablePlainTexts.title || "",
125
+ filter: filters[0]?.value || "",
126
+ layout: properties.layoutId || "",
127
+ maxItems: properties.maxItemsPerPage ?? 8,
128
+ });
129
+ }
130
+ else if (type === "QuickLinks") {
131
+ const linksMap = serverContent.links || {};
132
+ const links = [];
133
+ let i = 0;
134
+ while (searchablePlainTexts[`items[${i}].title`] !== undefined) {
135
+ links.push({
136
+ title: searchablePlainTexts[`items[${i}].title`] || "",
137
+ description: searchablePlainTexts[`items[${i}].description`] || "",
138
+ url: linksMap[`items[${i}].sourceItem.url`] || "",
139
+ });
140
+ i++;
141
+ }
142
+ webParts.push({
143
+ instanceId,
144
+ type: "QuickLinks",
145
+ zoneIndex,
146
+ sectionIndex,
147
+ layout: properties.layoutId || "",
148
+ links,
149
+ });
150
+ }
151
+ else {
152
+ webParts.push({
153
+ instanceId,
154
+ type: "Unknown",
155
+ webPartId,
156
+ zoneIndex,
157
+ sectionIndex,
158
+ });
159
+ }
160
+ }
161
+ }
162
+ return {
163
+ id: result.d.Id,
164
+ title: result.d.Title,
165
+ fileName: result.d.FileLeafRef,
166
+ webParts,
167
+ textSections,
168
+ };
169
+ }
20
170
  export async function setPageCanvasContent(siteUrl, pageItemId, canvasContent) {
21
171
  const listId = await getSitePagesListId(siteUrl);
22
172
  // Update CanvasContent1 field using MERGE
@@ -36,10 +186,200 @@ export async function copyPage(siteUrl, sourceFileName, targetFileName) {
36
186
  target: targetFileName,
37
187
  };
38
188
  }
39
- export async function listSitePages(siteUrl) {
189
+ // ============ PATCH WEB PART (string-replace, never JSON round-trip) ============
190
+ // HTML entity encoding for values going INTO data-sp-webpartdata attribute.
191
+ // SharePoint uses ASCII-special entities but leaves Unicode as literal UTF-8.
192
+ // Order matters: & must be first to avoid double-encoding.
193
+ function encodeForWebpartData(str) {
194
+ return str
195
+ .replace(/&/g, "&amp;")
196
+ .replace(/</g, "&lt;")
197
+ .replace(/>/g, "&gt;")
198
+ .replace(/"/g, "&quot;")
199
+ .replace(/\{/g, "&#123;")
200
+ .replace(/\}/g, "&#125;")
201
+ .replace(/:/g, "&#58;")
202
+ .replace(/\[/g, "&#91;")
203
+ .replace(/\]/g, "&#93;");
204
+ }
205
+ // HTML escape for plain text going INTO a html div (data-sp-htmlproperties block).
206
+ // Only escape characters that would break HTML parsing. Everything else stays literal.
207
+ function escapeForHtmlText(str) {
208
+ return str
209
+ .replace(/&/g, "&amp;")
210
+ .replace(/</g, "&lt;")
211
+ .replace(/>/g, "&gt;");
212
+ }
213
+ // Locate the data-sp-webpartdata="..." attribute value containing a given instanceId.
214
+ // Returns { attrStart, attrEnd } as indices into the canvas string, delimiting the
215
+ // value between the opening `data-sp-webpartdata="` and the closing `"`.
216
+ // Returns null if not found.
217
+ function locateWebpartDataAttr(canvas, instanceId) {
218
+ const encodedInstanceIdPattern = `&quot;instanceId&quot;&#58;&quot;${instanceId}&quot;`;
219
+ const instanceIdPos = canvas.indexOf(encodedInstanceIdPattern);
220
+ if (instanceIdPos === -1)
221
+ return null;
222
+ const attrOpenMarker = 'data-sp-webpartdata="';
223
+ const attrOpenPos = canvas.lastIndexOf(attrOpenMarker, instanceIdPos);
224
+ if (attrOpenPos === -1)
225
+ return null;
226
+ const attrStart = attrOpenPos + attrOpenMarker.length;
227
+ const attrEnd = canvas.indexOf('"', attrStart);
228
+ if (attrEnd === -1)
229
+ return null;
230
+ if (instanceIdPos >= attrEnd)
231
+ return null;
232
+ return { attrStart, attrEnd };
233
+ }
234
+ // Locate the <div data-sp-prop-name="title" ...>TEXT</div> element for a specific
235
+ // web part. The htmlproperties div comes AFTER webpartdata attribute within the
236
+ // same <div data-sp-webpart=""> block. We scan forward from the end of webpartdata
237
+ // and stop before any next data-sp-webpart= (which would belong to a different web part).
238
+ function locateHtmlPropertiesTitle(canvas, webpartAttrEnd) {
239
+ const nextWebpartPos = canvas.indexOf('data-sp-webpart="', webpartAttrEnd);
240
+ const searchLimit = nextWebpartPos === -1 ? canvas.length : nextWebpartPos;
241
+ const openMarker = '<div data-sp-prop-name="title" data-sp-searchableplaintext="true">';
242
+ const openPos = canvas.indexOf(openMarker, webpartAttrEnd);
243
+ if (openPos === -1 || openPos >= searchLimit)
244
+ return null;
245
+ const titleStart = openPos + openMarker.length;
246
+ const closeMarker = '</div>';
247
+ const closePos = canvas.indexOf(closeMarker, titleStart);
248
+ if (closePos === -1 || closePos >= searchLimit)
249
+ return null;
250
+ return { titleStart, titleEnd: closePos };
251
+ }
252
+ export async function patchPageCanvasWebpart(siteUrl, pageItemId, patches, verify = true) {
253
+ // 1. Read current canvas
254
+ const { canvasContent: originalCanvas } = await getPageCanvasContent(siteUrl, pageItemId);
255
+ if (!originalCanvas) {
256
+ throw new Error(`Page ${pageItemId} has empty canvasContent — nothing to patch`);
257
+ }
258
+ // 2. Use summary to get authoritative current values per instanceId
259
+ // (string-replace needs old values to search for)
260
+ const summary = await getPageCanvasSummary(siteUrl, pageItemId);
261
+ const summaryByInstanceId = new Map();
262
+ for (const wp of summary.webParts) {
263
+ summaryByInstanceId.set(wp.instanceId, wp);
264
+ }
265
+ let canvas = originalCanvas;
266
+ const patched = [];
267
+ const notFound = [];
268
+ for (const patch of patches) {
269
+ const currentWp = summaryByInstanceId.get(patch.instanceId);
270
+ if (!currentWp) {
271
+ notFound.push(patch.instanceId);
272
+ continue;
273
+ }
274
+ const webpartAttr = locateWebpartDataAttr(canvas, patch.instanceId);
275
+ if (!webpartAttr) {
276
+ notFound.push(patch.instanceId);
277
+ continue;
278
+ }
279
+ let webpartValue = canvas.slice(webpartAttr.attrStart, webpartAttr.attrEnd);
280
+ const beforeAttr = canvas.slice(0, webpartAttr.attrStart);
281
+ const afterAttr = canvas.slice(webpartAttr.attrEnd);
282
+ if (patch.title !== undefined) {
283
+ const oldTitle = currentWp.title || "";
284
+ const encodedOld = encodeForWebpartData(oldTitle);
285
+ const encodedNew = encodeForWebpartData(patch.title);
286
+ // Match inside searchablePlainTexts only — don't touch the generic wp.title "Highlighted content"
287
+ const oldPattern = `&quot;searchablePlainTexts&quot;&#58;&#123;&quot;title&quot;&#58;&quot;${encodedOld}&quot;`;
288
+ const newPattern = `&quot;searchablePlainTexts&quot;&#58;&#123;&quot;title&quot;&#58;&quot;${encodedNew}&quot;`;
289
+ webpartValue = webpartValue.replace(oldPattern, newPattern);
290
+ }
291
+ if (patch.filter !== undefined && currentWp.type === "HighlightedContent") {
292
+ const oldFilter = currentWp.filter || "";
293
+ const encodedOld = encodeForWebpartData(oldFilter);
294
+ const encodedNew = encodeForWebpartData(patch.filter);
295
+ const oldPattern = `&quot;filters&quot;&#58;[&#123;&quot;filterType&quot;&#58;1,&quot;value&quot;&#58;&quot;${encodedOld}&quot;`;
296
+ const newPattern = `&quot;filters&quot;&#58;[&#123;&quot;filterType&quot;&#58;1,&quot;value&quot;&#58;&quot;${encodedNew}&quot;`;
297
+ webpartValue = webpartValue.replace(oldPattern, newPattern);
298
+ }
299
+ if (patch.maxItems !== undefined && currentWp.type === "HighlightedContent") {
300
+ const oldMax = currentWp.maxItems;
301
+ const oldPattern = `&quot;maxItemsPerPage&quot;&#58;${oldMax},`;
302
+ const newPattern = `&quot;maxItemsPerPage&quot;&#58;${patch.maxItems},`;
303
+ webpartValue = webpartValue.replace(oldPattern, newPattern);
304
+ }
305
+ if (patch.layout !== undefined) {
306
+ const oldLayout = currentWp.layout || "";
307
+ const encodedOld = encodeForWebpartData(oldLayout);
308
+ const encodedNew = encodeForWebpartData(patch.layout);
309
+ const oldPattern = `&quot;layoutId&quot;&#58;&quot;${encodedOld}&quot;`;
310
+ const newPattern = `&quot;layoutId&quot;&#58;&quot;${encodedNew}&quot;`;
311
+ webpartValue = webpartValue.replace(oldPattern, newPattern);
312
+ }
313
+ canvas = beforeAttr + webpartValue + afterAttr;
314
+ // Title also lives in data-sp-htmlproperties div (plain HTML, not entity-encoded).
315
+ // Re-locate after the earlier string replace to get up-to-date indices.
316
+ if (patch.title !== undefined) {
317
+ const relocated = locateWebpartDataAttr(canvas, patch.instanceId);
318
+ if (relocated) {
319
+ const titleLoc = locateHtmlPropertiesTitle(canvas, relocated.attrEnd);
320
+ if (titleLoc) {
321
+ const escapedNewTitle = escapeForHtmlText(patch.title);
322
+ canvas =
323
+ canvas.slice(0, titleLoc.titleStart) +
324
+ escapedNewTitle +
325
+ canvas.slice(titleLoc.titleEnd);
326
+ }
327
+ }
328
+ }
329
+ patched.push(patch.instanceId);
330
+ }
331
+ // 3. Write the modified canvas back
332
+ await setPageCanvasContent(siteUrl, pageItemId, canvas);
333
+ // 4. Optional verification: read back and confirm new values are present
334
+ let verifyFailed;
335
+ if (verify && patched.length > 0) {
336
+ const verifySummary = await getPageCanvasSummary(siteUrl, pageItemId);
337
+ const verifyByInstanceId = new Map();
338
+ for (const wp of verifySummary.webParts) {
339
+ verifyByInstanceId.set(wp.instanceId, wp);
340
+ }
341
+ const failed = [];
342
+ for (const patch of patches) {
343
+ if (!patched.includes(patch.instanceId))
344
+ continue;
345
+ const wp = verifyByInstanceId.get(patch.instanceId);
346
+ if (!wp) {
347
+ failed.push(patch.instanceId);
348
+ continue;
349
+ }
350
+ if (patch.title !== undefined && wp.title !== patch.title) {
351
+ failed.push(patch.instanceId);
352
+ continue;
353
+ }
354
+ if (patch.filter !== undefined && wp.filter !== patch.filter) {
355
+ failed.push(patch.instanceId);
356
+ continue;
357
+ }
358
+ if (patch.maxItems !== undefined && wp.maxItems !== patch.maxItems) {
359
+ failed.push(patch.instanceId);
360
+ continue;
361
+ }
362
+ if (patch.layout !== undefined && wp.layout !== patch.layout) {
363
+ failed.push(patch.instanceId);
364
+ continue;
365
+ }
366
+ }
367
+ if (failed.length > 0) {
368
+ verifyFailed = failed;
369
+ }
370
+ }
371
+ return {
372
+ success: notFound.length === 0 && (!verifyFailed || verifyFailed.length === 0),
373
+ pageItemId,
374
+ patched,
375
+ notFound,
376
+ ...(verifyFailed ? { verifyFailed } : {}),
377
+ };
378
+ }
379
+ export async function fetchSitePageItemIdMap(siteUrl) {
40
380
  const listId = await getSitePagesListId(siteUrl);
41
381
  const allPages = [];
42
- let nextPath = `/_api/web/lists(guid'${listId}')/items?$select=Id,Title,FileLeafRef&$top=100`;
382
+ let nextPath = `/_api/web/lists(guid'${listId}')/items?$select=Id,FileLeafRef&$top=100`;
43
383
  while (nextPath) {
44
384
  const result = await callSharePointRest(siteUrl, nextPath, "GET");
45
385
  allPages.push(...result.d.results);
@@ -51,9 +391,5 @@ export async function listSitePages(siteUrl) {
51
391
  nextPath = null;
52
392
  }
53
393
  }
54
- return allPages.map((page) => ({
55
- itemId: page.Id,
56
- title: page.Title,
57
- fileName: page.FileLeafRef,
58
- }));
394
+ return new Map(allPages.map((page) => [page.FileLeafRef, page.Id]));
59
395
  }
@@ -1,5 +1,5 @@
1
- export declare function listPages(siteId: string): Promise<any>;
2
- export declare function createPage(siteId: string, title: string, name: string, layoutType?: string): Promise<{
1
+ export declare function listPages(siteId: string, siteUrl?: string, includeItemId?: boolean): Promise<any>;
2
+ export declare function createPage(siteId: string, title: string, name: string, pageLayout?: "article" | "home"): Promise<{
3
3
  id: any;
4
4
  name: any;
5
5
  title: any;
@@ -1,24 +1,39 @@
1
1
  import { graphClient } from "../auth.js";
2
- export async function listPages(siteId) {
3
- const result = await graphClient
2
+ import { fetchSitePageItemIdMap } from "./pages-rest.js";
3
+ export async function listPages(siteId, siteUrl, includeItemId = false) {
4
+ const graphResult = await graphClient
4
5
  .api(`/sites/${siteId}/pages`)
5
6
  .get();
6
- return result.value.map((page) => ({
7
+ if (!includeItemId) {
8
+ return graphResult.value.map((page) => ({
9
+ id: page.id,
10
+ name: page.name,
11
+ title: page.title,
12
+ url: page.webUrl,
13
+ publishingState: page.publishingState?.level,
14
+ }));
15
+ }
16
+ if (!siteUrl) {
17
+ throw new Error("siteUrl is required when includeItemId is true");
18
+ }
19
+ const fileNameToItemId = await fetchSitePageItemIdMap(siteUrl);
20
+ return graphResult.value.map((page) => ({
7
21
  id: page.id,
22
+ itemId: fileNameToItemId.get(page.name),
8
23
  name: page.name,
9
24
  title: page.title,
10
25
  url: page.webUrl,
11
26
  publishingState: page.publishingState?.level,
12
27
  }));
13
28
  }
14
- export async function createPage(siteId, title, name, layoutType = "article") {
29
+ export async function createPage(siteId, title, name, pageLayout = "article") {
15
30
  const result = await graphClient
16
31
  .api(`/sites/${siteId}/pages`)
17
32
  .post({
18
33
  "@odata.type": "#microsoft.graph.sitePage",
19
34
  name: name.endsWith(".aspx") ? name : `${name}.aspx`,
20
35
  title,
21
- pageLayout: layoutType,
36
+ pageLayout,
22
37
  showComments: false,
23
38
  showRecommendedPages: false,
24
39
  titleArea: {
@@ -1,4 +1,4 @@
1
- export declare function getSitePermissions(siteUrl: string): Promise<any>;
1
+ export declare function getSitePermissions(siteUrl: string, includeMembers?: boolean): Promise<any>;
2
2
  export declare function getGroupMembers(siteUrl: string, groupId: number): Promise<any>;
3
3
  export declare function addUserToGroup(siteUrl: string, groupId: number, userEmail: string): Promise<{
4
4
  id: any;
@@ -1,14 +1,30 @@
1
1
  import { callSharePointRest } from "../auth.js";
2
- export async function getSitePermissions(siteUrl) {
3
- // Get all SharePoint groups for this site
4
- const result = await callSharePointRest(siteUrl, "/_api/web/sitegroups", "GET");
5
- return result.d.results.map((group) => ({
6
- id: group.Id,
7
- title: group.Title,
8
- description: group.Description,
9
- ownerTitle: group.OwnerTitle,
10
- userCount: group.Users ? group.Users.results?.length : undefined,
11
- }));
2
+ export async function getSitePermissions(siteUrl, includeMembers = false) {
3
+ // Default: groups only. With includeMembers: expand Users and select minimal fields
4
+ // to avoid the N+1 pattern (get_permissions + N × get_group_members).
5
+ const apiPath = includeMembers
6
+ ? "/_api/web/sitegroups?$expand=Users&$select=Id,Title,Description,OwnerTitle,Users/Id,Users/Title,Users/Email,Users/LoginName"
7
+ : "/_api/web/sitegroups";
8
+ const result = await callSharePointRest(siteUrl, apiPath, "GET");
9
+ return result.d.results.map((group) => {
10
+ const base = {
11
+ id: group.Id,
12
+ title: group.Title,
13
+ description: group.Description,
14
+ ownerTitle: group.OwnerTitle,
15
+ };
16
+ if (!includeMembers)
17
+ return base;
18
+ return {
19
+ ...base,
20
+ members: (group.Users?.results || []).map((u) => ({
21
+ id: u.Id,
22
+ title: u.Title,
23
+ email: u.Email,
24
+ loginName: u.LoginName,
25
+ })),
26
+ };
27
+ });
12
28
  }
13
29
  export async function getGroupMembers(siteUrl, groupId) {
14
30
  const result = await callSharePointRest(siteUrl, `/_api/web/sitegroups/getbyid(${groupId})/users`, "GET");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "365center-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for Microsoft 365 / Office 365 SharePoint — full read-write access to sites, documents, pages, metadata, navigation, and permissions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,9 +39,10 @@
39
39
  },
40
40
  "homepage": "https://github.com/Crscristi28/365center-mcp#readme",
41
41
  "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.12.1",
43
42
  "@azure/identity": "^4.9.1",
44
43
  "@microsoft/microsoft-graph-client": "^3.0.7",
44
+ "@modelcontextprotocol/sdk": "^1.12.1",
45
+ "@napi-rs/keyring": "^1.2.0",
45
46
  "dotenv": "^16.5.0"
46
47
  },
47
48
  "devDependencies": {