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 +16 -14
- package/dist/auth.js +81 -34
- package/dist/index.js +60 -27
- package/dist/tools/documents.d.ts +1 -1
- package/dist/tools/documents.js +19 -6
- package/dist/tools/metadata.d.ts +4 -0
- package/dist/tools/metadata.js +10 -1
- package/dist/tools/pages-rest.d.ts +62 -5
- package/dist/tools/pages-rest.js +343 -7
- package/dist/tools/pages.d.ts +2 -2
- package/dist/tools/pages.js +20 -5
- package/dist/tools/permissions.d.ts +1 -1
- package/dist/tools/permissions.js +26 -10
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 365center-mcp
|
|
2
2
|
|
|
3
|
-
**MCP server for Microsoft 365 / SharePoint —
|
|
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 **
|
|
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
|
-
**
|
|
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 (
|
|
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 (
|
|
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 (
|
|
94
|
-
- `
|
|
95
|
-
- `get_page_canvas_content` — Read raw
|
|
96
|
-
- `set_page_canvas_content` — Write raw page content (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
let cachedAccessToken = null;
|
|
34
|
+
function loadRefreshToken() {
|
|
31
35
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
refreshToken: tokenData.refresh_token,
|
|
128
|
+
cachedAccessToken = {
|
|
129
|
+
token: tokenData.access_token,
|
|
86
130
|
expiresAt: Date.now() + tokenData.expires_in * 1000,
|
|
87
131
|
};
|
|
88
|
-
|
|
132
|
+
if (tokenData.refresh_token) {
|
|
133
|
+
saveRefreshToken(tokenData.refresh_token);
|
|
134
|
+
}
|
|
89
135
|
deviceCodePollingPromise = null;
|
|
90
|
-
resolve(
|
|
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.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
return refreshed.accessToken;
|
|
191
|
+
return await refreshAccessToken(refreshToken);
|
|
144
192
|
}
|
|
145
193
|
catch {
|
|
146
|
-
// Refresh failed —
|
|
194
|
+
// Refresh failed (expired/revoked) — fall through to device code flow
|
|
147
195
|
}
|
|
148
196
|
}
|
|
149
|
-
//
|
|
197
|
+
// 3. If polling is already running, wait for it
|
|
150
198
|
if (deviceCodePollingPromise) {
|
|
151
|
-
|
|
152
|
-
return result.accessToken;
|
|
199
|
+
return await deviceCodePollingPromise;
|
|
153
200
|
}
|
|
154
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
244
|
-
|
|
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
|
|
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
|
|
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("
|
|
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 ({ } : ") — 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
|
|
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;
|
package/dist/tools/documents.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
213
|
+
if (i < files.length - 1) {
|
|
214
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
215
|
+
}
|
|
203
216
|
}
|
|
204
217
|
return results;
|
|
205
218
|
}
|
package/dist/tools/metadata.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/metadata.js
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
|
|
18
|
-
title
|
|
19
|
-
|
|
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 {};
|
package/dist/tools/pages-rest.js
CHANGED
|
@@ -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(/"/g, '"')
|
|
28
|
+
.replace(/{/g, "{")
|
|
29
|
+
.replace(/}/g, "}")
|
|
30
|
+
.replace(/:/g, ":")
|
|
31
|
+
.replace(/[/g, "[")
|
|
32
|
+
.replace(/]/g, "]")
|
|
33
|
+
.replace(/&/g, "&")
|
|
34
|
+
.replace(/</g, "<")
|
|
35
|
+
.replace(/>/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
|
-
|
|
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, "&")
|
|
196
|
+
.replace(/</g, "<")
|
|
197
|
+
.replace(/>/g, ">")
|
|
198
|
+
.replace(/"/g, """)
|
|
199
|
+
.replace(/\{/g, "{")
|
|
200
|
+
.replace(/\}/g, "}")
|
|
201
|
+
.replace(/:/g, ":")
|
|
202
|
+
.replace(/\[/g, "[")
|
|
203
|
+
.replace(/\]/g, "]");
|
|
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, "&")
|
|
210
|
+
.replace(/</g, "<")
|
|
211
|
+
.replace(/>/g, ">");
|
|
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 = `"instanceId":"${instanceId}"`;
|
|
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 = `"searchablePlainTexts":{"title":"${encodedOld}"`;
|
|
288
|
+
const newPattern = `"searchablePlainTexts":{"title":"${encodedNew}"`;
|
|
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 = `"filters":[{"filterType":1,"value":"${encodedOld}"`;
|
|
296
|
+
const newPattern = `"filters":[{"filterType":1,"value":"${encodedNew}"`;
|
|
297
|
+
webpartValue = webpartValue.replace(oldPattern, newPattern);
|
|
298
|
+
}
|
|
299
|
+
if (patch.maxItems !== undefined && currentWp.type === "HighlightedContent") {
|
|
300
|
+
const oldMax = currentWp.maxItems;
|
|
301
|
+
const oldPattern = `"maxItemsPerPage":${oldMax},`;
|
|
302
|
+
const newPattern = `"maxItemsPerPage":${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 = `"layoutId":"${encodedOld}"`;
|
|
310
|
+
const newPattern = `"layoutId":"${encodedNew}"`;
|
|
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,
|
|
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
|
}
|
package/dist/tools/pages.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export declare function listPages(siteId: string): Promise<any>;
|
|
2
|
-
export declare function createPage(siteId: string, title: string, name: string,
|
|
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;
|
package/dist/tools/pages.js
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
import { graphClient } from "../auth.js";
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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": {
|