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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 365center-mcp
2
2
 
3
- **MCP server for Microsoft 365 / SharePoint — 36 tools for full read/write access**
3
+ **MCP server for Microsoft 365 / SharePoint — 30+ tools for full read/write access**
4
4
 
5
5
  Available on [GitHub](https://github.com/Crscristi28/365center-mcp) · [npm](https://www.npmjs.com/package/365center-mcp) · [Docker Hub](https://hub.docker.com/r/crscristi28/365center-mcp) · [cristianb.cz](https://cristianb.cz)
6
6
 
@@ -56,7 +56,7 @@ Built for manufacturing companies managing factory documentation in SharePoint,
56
56
 
57
57
  ## Features
58
58
 
59
- **36 tools** across 7 categories. All tools use Microsoft Graph API or SharePoint REST API — no middlemen.
59
+ **30+ tools** across 7 categories. All tools use Microsoft Graph API or SharePoint REST API — no middlemen.
60
60
 
61
61
  ### Sites (4 tools)
62
62
  - `list_sites` — List all SharePoint sites in the tenant
@@ -65,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 (drives)
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 (5 tools)
78
+ ### Metadata (6 tools)
79
79
  - `list_columns` — List custom metadata columns
80
80
  - `create_choice_column` — Create choice/dropdown columns (single or multi-select)
81
81
  - `create_text_column` — Create text columns
82
+ - `delete_column` — Permanently delete a column from a list or document library (irreversible)
82
83
  - `get_document_metadata` — Read document metadata
83
84
  - `set_document_metadata` — Set metadata on documents
84
85
 
@@ -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
- // Store token in user's home directory works for Node, Docker (with volume), and plugins
24
+ // Refresh token storage: OS keyring (macOS Keychain / Windows Credential Manager / Linux libsecret)
25
+ // with file fallback (chmod 600) for headless / Docker / keyring-less environments.
26
+ // Access token is kept in-memory only — never written to disk.
27
+ const KEYRING_SERVICE = "365center-mcp";
28
+ const KEYRING_ACCOUNT = "refresh-token";
24
29
  const TOKEN_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "/tmp", ".365center-mcp");
25
- if (!fs.existsSync(TOKEN_DIR))
26
- fs.mkdirSync(TOKEN_DIR, { recursive: true });
27
- const TOKEN_CACHE_PATH = path.join(TOKEN_DIR, "token-cache.json");
30
+ const TOKEN_FILE = path.join(TOKEN_DIR, "refresh-token");
28
31
  const SHAREPOINT_DOMAIN = process.env.SHAREPOINT_DOMAIN || "";
29
32
  const SP_SCOPES = `offline_access https://${SHAREPOINT_DOMAIN}/AllSites.FullControl`;
30
- function loadTokenCache() {
33
+ let cachedAccessToken = null;
34
+ function loadRefreshToken() {
31
35
  try {
32
- if (fs.existsSync(TOKEN_CACHE_PATH)) {
33
- return JSON.parse(fs.readFileSync(TOKEN_CACHE_PATH, "utf-8"));
36
+ const v = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword();
37
+ if (v)
38
+ return v;
39
+ }
40
+ catch { }
41
+ try {
42
+ if (fs.existsSync(TOKEN_FILE)) {
43
+ return fs.readFileSync(TOKEN_FILE, "utf-8").trim() || null;
34
44
  }
35
45
  }
36
46
  catch { }
37
47
  return null;
38
48
  }
39
- function saveTokenCache(cache) {
40
- fs.writeFileSync(TOKEN_CACHE_PATH, JSON.stringify(cache, null, 2));
49
+ function saveRefreshToken(token) {
50
+ try {
51
+ new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).setPassword(token);
52
+ return;
53
+ }
54
+ catch { }
55
+ try {
56
+ if (!fs.existsSync(TOKEN_DIR)) {
57
+ fs.mkdirSync(TOKEN_DIR, { recursive: true });
58
+ }
59
+ try {
60
+ fs.chmodSync(TOKEN_DIR, 0o700);
61
+ }
62
+ catch { }
63
+ fs.writeFileSync(TOKEN_FILE, token);
64
+ try {
65
+ fs.chmodSync(TOKEN_FILE, 0o600);
66
+ }
67
+ catch { }
68
+ }
69
+ catch { }
70
+ }
71
+ function clearRefreshToken() {
72
+ try {
73
+ new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).deletePassword();
74
+ }
75
+ catch { }
76
+ try {
77
+ if (fs.existsSync(TOKEN_FILE))
78
+ fs.unlinkSync(TOKEN_FILE);
79
+ }
80
+ catch { }
81
+ cachedAccessToken = null;
41
82
  }
42
83
  async function refreshAccessToken(refreshToken) {
84
+ // Device code flow uses a public client — client_secret must NOT be sent
85
+ // (Microsoft returns AADSTS700025 if included).
43
86
  const body = new URLSearchParams({
44
87
  client_id: clientId,
45
- client_secret: clientSecret,
46
88
  grant_type: "refresh_token",
47
89
  refresh_token: refreshToken,
48
90
  scope: SP_SCOPES,
@@ -53,13 +95,16 @@ async function refreshAccessToken(refreshToken) {
53
95
  throw new Error(`Token refresh failed: ${err}`);
54
96
  }
55
97
  const data = await response.json();
56
- const cache = {
57
- accessToken: data.access_token,
58
- refreshToken: data.refresh_token || refreshToken,
98
+ const accessToken = data.access_token;
99
+ const newRefreshToken = data.refresh_token || refreshToken;
100
+ cachedAccessToken = {
101
+ token: accessToken,
59
102
  expiresAt: Date.now() + data.expires_in * 1000,
60
103
  };
61
- saveTokenCache(cache);
62
- return cache;
104
+ if (newRefreshToken !== refreshToken) {
105
+ saveRefreshToken(newRefreshToken);
106
+ }
107
+ return accessToken;
63
108
  }
64
109
  // Device code polling — runs in background after user gets instructions
65
110
  let deviceCodePollingPromise = null;
@@ -80,14 +125,15 @@ function pollForDeviceCodeToken(deviceCode, interval, expiresIn) {
80
125
  });
81
126
  const tokenData = await tokenResponse.json();
82
127
  if (tokenData.access_token) {
83
- const cache = {
84
- accessToken: tokenData.access_token,
85
- refreshToken: tokenData.refresh_token,
128
+ cachedAccessToken = {
129
+ token: tokenData.access_token,
86
130
  expiresAt: Date.now() + tokenData.expires_in * 1000,
87
131
  };
88
- saveTokenCache(cache);
132
+ if (tokenData.refresh_token) {
133
+ saveRefreshToken(tokenData.refresh_token);
134
+ }
89
135
  deviceCodePollingPromise = null;
90
- resolve(cache);
136
+ resolve(tokenData.access_token);
91
137
  return;
92
138
  }
93
139
  if (tokenData.error === "authorization_pending")
@@ -123,7 +169,9 @@ async function startDeviceCodeFlow() {
123
169
  }
124
170
  const codeData = await codeResponse.json();
125
171
  const { device_code, user_code, verification_uri, expires_in, interval, message } = codeData;
126
- // Start polling in background
172
+ // Start polling in background. Clear any stale refresh token so a stuck/invalid
173
+ // one from a previous run doesn't race the device code flow.
174
+ clearRefreshToken();
127
175
  deviceCodePollingPromise = pollForDeviceCodeToken(device_code, interval, expires_in);
128
176
  // Throw error with login instructions — Claude sees this and tells the user
129
177
  throw new Error(`LOGIN REQUIRED: ${message}\n\n` +
@@ -132,26 +180,25 @@ async function startDeviceCodeFlow() {
132
180
  `After logging in, try your request again.`);
133
181
  }
134
182
  export async function getDelegatedToken() {
135
- // 1. Check cache
136
- const cache = loadTokenCache();
137
- if (cache) {
138
- if (cache.expiresAt > Date.now() + 300000) {
139
- return cache.accessToken;
140
- }
183
+ // 1. In-memory access token still valid?
184
+ if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 300000) {
185
+ return cachedAccessToken.token;
186
+ }
187
+ // 2. Try refresh token from keyring/file
188
+ const refreshToken = loadRefreshToken();
189
+ if (refreshToken) {
141
190
  try {
142
- const refreshed = await refreshAccessToken(cache.refreshToken);
143
- return refreshed.accessToken;
191
+ return await refreshAccessToken(refreshToken);
144
192
  }
145
193
  catch {
146
- // Refresh failed — need new login
194
+ // Refresh failed (expired/revoked) fall through to device code flow
147
195
  }
148
196
  }
149
- // 2. If polling is already running, wait for it
197
+ // 3. If polling is already running, wait for it
150
198
  if (deviceCodePollingPromise) {
151
- const result = await deviceCodePollingPromise;
152
- return result.accessToken;
199
+ return await deviceCodePollingPromise;
153
200
  }
154
- // 3. No cache, no polling — start device code flow (throws with instructions)
201
+ // 4. No cache, no polling — start device code flow (throws with instructions)
155
202
  await startDeviceCodeFlow();
156
203
  throw new Error("unreachable"); // startDeviceCodeFlow always throws
157
204
  }
package/dist/index.js CHANGED
@@ -7,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 (drives) in a SharePoint site. Returns driveId and listId for each library. Use driveId for file operations (upload, download, delete, list, versions). Use listId for metadata operations (list_columns, get/set_document_metadata, create columns). Call this first when working with documents — you need both IDs.", { siteId: z.string().describe("SharePoint site ID") }, async ({ siteId }) => {
44
+ server.tool("list_document_libraries", "List all document libraries in a SharePoint site. Returns driveId, listId, name, and URL for each library. Use driveId for file operations (upload, download, delete, list, versions). Use listId for metadata operations (list_columns, get/set_document_metadata, create columns). Call this first when working with documents — you need both IDs.", { siteId: z.string().describe("SharePoint site ID") }, async ({ siteId }) => {
45
45
  const libraries = await listDocumentLibraries(siteId);
46
46
  return { content: [{ type: "text", text: JSON.stringify(libraries, null, 2) }] };
47
47
  });
@@ -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 document library list ID"),
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 document library list ID"),
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 document library list ID"),
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 document library list ID"),
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 document library list ID"),
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
- }, async ({ siteId, title, name }) => {
187
- const result = await createPage(siteId, title, name);
194
+ pageLayout: z.enum(["article", "home"]).optional().describe("Page layout. 'article' (default) — standard content page with title banner (image, title, author, date) above the canvas. Use for 99% of pages. 'home' — dashboard-style layout without title banner, canvas goes full-width from the top. Use only when you explicitly want a landing-page feel. Note: 'home' does NOT set the page as the site's actual home page — that's a separate site setting."),
195
+ }, async ({ siteId, title, name, pageLayout }) => {
196
+ const result = await createPage(siteId, title, name, pageLayout);
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 description. Use group ID with get_group_members to see who is in each group, or with add_user_to_group/remove_user_from_group to manage membership. Uses SharePoint REST API with delegated auth.", { siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)") }, async ({ siteUrl }) => {
249
- const result = await getSitePermissions(siteUrl);
257
+ server.tool("get_permissions", "Get all SharePoint groups (Visitors, Members, Owners, custom) for a site. Returns group ID, title, description, and owner title. With includeMembers: true, also returns all users in each group in a single call (id, title, email, loginName) avoids calling get_group_members separately for each group. Uses SharePoint REST API with delegated auth.", {
258
+ siteUrl: z.string().describe("Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite)"),
259
+ includeMembers: z.boolean().optional().describe("If true, include member list (id, title, email, loginName) inside each group — single call for full permission overview. If false/omitted, returns groups only."),
260
+ }, async ({ siteUrl, includeMembers }) => {
261
+ const result = await getSitePermissions(siteUrl, includeMembers);
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.", {
@@ -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
- id: drive.id,
13
+ driveId: drive.id,
14
+ listId: drive.list?.id,
12
15
  name: drive.name,
13
16
  url: drive.webUrl,
14
- itemCount: drive.quota?.used,
15
17
  }));
16
18
  }
17
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 (const file of files) {
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
- await new Promise((r) => setTimeout(r, 500));
213
+ if (i < files.length - 1) {
214
+ await new Promise((r) => setTimeout(r, 500));
215
+ }
211
216
  }
212
217
  return results;
213
218
  }
@@ -5,6 +5,10 @@ export declare function createChoiceColumn(siteId: string, listId: string, name:
5
5
  displayName: any;
6
6
  allowMultiple: boolean;
7
7
  }>;
8
+ export declare function deleteColumn(siteId: string, listId: string, columnId: string): Promise<{
9
+ success: boolean;
10
+ columnId: string;
11
+ }>;
8
12
  export declare function createTextColumn(siteId: string, listId: string, name: string, displayName: string): Promise<{
9
13
  id: any;
10
14
  name: any;
@@ -61,7 +61,10 @@ export async function createChoiceColumn(siteId, listId, name, displayName, choi
61
61
  }
62
62
  const site = await graphClient.api(`/sites/${siteId}`).select("webUrl").get();
63
63
  const siteUrl = site.webUrl;
64
- const result = await callSharePointRest(siteUrl, `/_api/web/lists/getByTitle('${listId}')/fields`, "POST", {
64
+ const listIdentifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(listId)
65
+ ? `lists(guid'${listId}')`
66
+ : `lists/getByTitle('${listId}')`;
67
+ const result = await callSharePointRest(siteUrl, `/_api/web/${listIdentifier}/fields`, "POST", {
65
68
  __metadata: { type: "SP.FieldMultiChoice" },
66
69
  FieldTypeKind: 15,
67
70
  Title: displayName,
@@ -76,6 +79,12 @@ export async function createChoiceColumn(siteId, listId, name, displayName, choi
76
79
  allowMultiple: true,
77
80
  };
78
81
  }
82
+ export async function deleteColumn(siteId, listId, columnId) {
83
+ await graphClient
84
+ .api(`/sites/${siteId}/lists/${listId}/columns/${columnId}`)
85
+ .delete();
86
+ return { success: true, columnId };
87
+ }
79
88
  export async function createTextColumn(siteId, listId, name, displayName) {
80
89
  const result = await graphClient
81
90
  .api(`/sites/${siteId}/lists/${listId}/columns`)
@@ -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, layoutType?: string): Promise<{
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;
@@ -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, layoutType = "article") {
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: layoutType,
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
- // Get all SharePoint groups for this site
4
- const result = await callSharePointRest(siteUrl, "/_api/web/sitegroups", "GET");
5
- return result.d.results.map((group) => ({
6
- id: group.Id,
7
- title: group.Title,
8
- description: group.Description,
9
- ownerTitle: group.OwnerTitle,
10
- userCount: group.Users ? group.Users.results?.length : undefined,
11
- }));
2
+ export async function getSitePermissions(siteUrl, includeMembers = false) {
3
+ // Default: groups only. With includeMembers: expand Users and select minimal fields
4
+ // to avoid the N+1 pattern (get_permissions + N × get_group_members).
5
+ const apiPath = includeMembers
6
+ ? "/_api/web/sitegroups?$expand=Users&$select=Id,Title,Description,OwnerTitle,Users/Id,Users/Title,Users/Email,Users/LoginName"
7
+ : "/_api/web/sitegroups";
8
+ const result = await callSharePointRest(siteUrl, apiPath, "GET");
9
+ return result.d.results.map((group) => {
10
+ const base = {
11
+ id: group.Id,
12
+ title: group.Title,
13
+ description: group.Description,
14
+ ownerTitle: group.OwnerTitle,
15
+ };
16
+ if (!includeMembers)
17
+ return base;
18
+ return {
19
+ ...base,
20
+ members: (group.Users?.results || []).map((u) => ({
21
+ id: u.Id,
22
+ title: u.Title,
23
+ email: u.Email,
24
+ loginName: u.LoginName,
25
+ })),
26
+ };
27
+ });
12
28
  }
13
29
  export async function getGroupMembers(siteUrl, groupId) {
14
30
  const result = await callSharePointRest(siteUrl, `/_api/web/sitegroups/getbyid(${groupId})/users`, "GET");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "365center-mcp",
3
- "version": "1.3.0",
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": {