365center-mcp 1.3.0 → 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 +6 -5
- package/dist/auth.js +81 -34
- package/dist/index.js +23 -11
- package/dist/tools/documents.js +10 -5
- package/dist/tools/metadata.d.ts +4 -0
- package/dist/tools/metadata.js +10 -1
- package/dist/tools/pages.d.ts +1 -1
- package/dist/tools/pages.js +2 -2
- 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
|
|
|
@@ -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,7 +65,7 @@ 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 (
|
|
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
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)
|
|
@@ -75,10 +75,11 @@ 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
|
|
|
@@ -103,7 +104,7 @@ Built for manufacturing companies managing factory documentation in SharePoint,
|
|
|
103
104
|
- `delete_navigation_link` — Remove link from navigation
|
|
104
105
|
|
|
105
106
|
### Permissions (4 tools)
|
|
106
|
-
- `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.
|
|
107
108
|
- `get_group_members` — List members of a group
|
|
108
109
|
- `add_user_to_group` — Add user to a group
|
|
109
110
|
- `remove_user_from_group` — Remove user from a group
|
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,7 +7,7 @@ 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
13
|
import { getPageCanvasContent, getPageCanvasSummary, setPageCanvasContent, copyPage, patchPageCanvasWebpart } from "./tools/pages-rest.js";
|
|
@@ -41,7 +41,7 @@ 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
|
});
|
|
@@ -125,14 +125,14 @@ server.tool("get_document_versions", "Get version history of a document (audit t
|
|
|
125
125
|
// ============ METADATA ============
|
|
126
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.", {
|
|
127
127
|
siteId: z.string().describe("SharePoint site ID"),
|
|
128
|
-
listId: z.string().describe("List or
|
|
128
|
+
listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
|
|
129
129
|
}, async ({ siteId, listId }) => {
|
|
130
130
|
const columns = await listColumns(siteId, listId);
|
|
131
131
|
return { content: [{ type: "text", text: JSON.stringify(columns, null, 2) }] };
|
|
132
132
|
});
|
|
133
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.", {
|
|
134
134
|
siteId: z.string().describe("SharePoint site ID"),
|
|
135
|
-
listId: z.string().describe("List or
|
|
135
|
+
listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
|
|
136
136
|
name: z.string().describe("Internal column name"),
|
|
137
137
|
displayName: z.string().describe("Display name shown in UI"),
|
|
138
138
|
choices: z.array(z.string()).describe("List of choices"),
|
|
@@ -143,16 +143,24 @@ server.tool("create_choice_column", "Create a choice/dropdown metadata column in
|
|
|
143
143
|
});
|
|
144
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.", {
|
|
145
145
|
siteId: z.string().describe("SharePoint site ID"),
|
|
146
|
-
listId: z.string().describe("List or
|
|
146
|
+
listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
|
|
147
147
|
name: z.string().describe("Internal column name"),
|
|
148
148
|
displayName: z.string().describe("Display name shown in UI"),
|
|
149
149
|
}, async ({ siteId, listId, name, displayName }) => {
|
|
150
150
|
const result = await createTextColumn(siteId, listId, name, displayName);
|
|
151
151
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
152
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
|
+
});
|
|
153
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.", {
|
|
154
162
|
siteId: z.string().describe("SharePoint site ID"),
|
|
155
|
-
listId: z.string().describe("List or
|
|
163
|
+
listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
|
|
156
164
|
itemId: z.string().describe("Document ID — either numeric list item ID or drive item ID"),
|
|
157
165
|
fields: z.string().describe("JSON string of key-value pairs, e.g. {\"Oblast\":\"Linka 1\",\"Status\":\"Platný\"}"),
|
|
158
166
|
driveId: z.string().optional().describe("Drive ID — required when itemId is a drive item ID (non-numeric)"),
|
|
@@ -163,7 +171,7 @@ server.tool("set_document_metadata", "Set metadata fields on a document. 'fields
|
|
|
163
171
|
});
|
|
164
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.", {
|
|
165
173
|
siteId: z.string().describe("SharePoint site ID"),
|
|
166
|
-
listId: z.string().describe("List or
|
|
174
|
+
listId: z.string().describe("List ID (GUID) or display name (e.g. 'Documents')"),
|
|
167
175
|
itemId: z.string().describe("Document ID — either numeric list item ID or drive item ID"),
|
|
168
176
|
driveId: z.string().optional().describe("Drive ID — required when itemId is a drive item ID (non-numeric)"),
|
|
169
177
|
}, async ({ siteId, listId, itemId, driveId }) => {
|
|
@@ -183,8 +191,9 @@ server.tool("create_page", "Create a new empty page in checkout/draft state. MUS
|
|
|
183
191
|
siteId: z.string().describe("SharePoint site ID"),
|
|
184
192
|
title: z.string().describe("Page title"),
|
|
185
193
|
name: z.string().describe("Page file name (without .aspx)"),
|
|
186
|
-
|
|
187
|
-
|
|
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);
|
|
188
197
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
189
198
|
});
|
|
190
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.", {
|
|
@@ -245,8 +254,11 @@ server.tool("delete_navigation_link", "Remove a link from the top navigation men
|
|
|
245
254
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
246
255
|
});
|
|
247
256
|
// ============ PERMISSIONS ============
|
|
248
|
-
server.tool("get_permissions", "Get all SharePoint groups (Visitors, Members, Owners, custom) for a site. Returns group ID, title, and
|
|
249
|
-
|
|
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);
|
|
250
262
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
251
263
|
});
|
|
252
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.", {
|
package/dist/tools/documents.js
CHANGED
|
@@ -4,14 +4,16 @@ 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
19
|
export async function listDocuments(siteId, driveId, folderId = "root", fields = "all") {
|
|
@@ -181,7 +183,8 @@ export async function getDocumentVersions(siteId, driveId, itemId) {
|
|
|
181
183
|
}
|
|
182
184
|
export async function uploadDocuments(siteId, driveId, listId, files, folderId = "root") {
|
|
183
185
|
const results = [];
|
|
184
|
-
for (
|
|
186
|
+
for (let i = 0; i < files.length; i++) {
|
|
187
|
+
const file = files[i];
|
|
185
188
|
const entry = { fileName: file.fileName, status: "ok" };
|
|
186
189
|
try {
|
|
187
190
|
const uploaded = await uploadDocument(siteId, driveId, file.fileName, file.filePath, folderId);
|
|
@@ -207,7 +210,9 @@ export async function uploadDocuments(siteId, driveId, listId, files, folderId =
|
|
|
207
210
|
entry.metadataStatus = "skipped";
|
|
208
211
|
}
|
|
209
212
|
results.push(entry);
|
|
210
|
-
|
|
213
|
+
if (i < files.length - 1) {
|
|
214
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
215
|
+
}
|
|
211
216
|
}
|
|
212
217
|
return results;
|
|
213
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`)
|
package/dist/tools/pages.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare function listPages(siteId: string, siteUrl?: string, includeItemId?: boolean): Promise<any>;
|
|
2
|
-
export declare function createPage(siteId: string, title: string, name: string,
|
|
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
|
@@ -26,14 +26,14 @@ export async function listPages(siteId, siteUrl, includeItemId = false) {
|
|
|
26
26
|
publishingState: page.publishingState?.level,
|
|
27
27
|
}));
|
|
28
28
|
}
|
|
29
|
-
export async function createPage(siteId, title, name,
|
|
29
|
+
export async function createPage(siteId, title, name, pageLayout = "article") {
|
|
30
30
|
const result = await graphClient
|
|
31
31
|
.api(`/sites/${siteId}/pages`)
|
|
32
32
|
.post({
|
|
33
33
|
"@odata.type": "#microsoft.graph.sitePage",
|
|
34
34
|
name: name.endsWith(".aspx") ? name : `${name}.aspx`,
|
|
35
35
|
title,
|
|
36
|
-
pageLayout
|
|
36
|
+
pageLayout,
|
|
37
37
|
showComments: false,
|
|
38
38
|
showRecommendedPages: false,
|
|
39
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": {
|