mcp-sharepoint-us 2.0.10__tar.gz → 2.0.12__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-sharepoint-us
3
- Version: 2.0.10
3
+ Version: 2.0.12
4
4
  Summary: SharePoint MCP Server with Modern Azure AD Authentication
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/mdev26/mcp-sharepoint-us
@@ -22,6 +22,7 @@ Requires-Dist: office365-rest-python-client>=2.5.0
22
22
  Requires-Dist: msal>=1.24.0
23
23
  Requires-Dist: python-dotenv>=1.0.0
24
24
  Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: requests>=2.31.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=7.0.0; extra == "dev"
27
28
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mcp-sharepoint-us"
7
- version = "2.0.10"
7
+ version = "2.0.12"
8
8
  description = "SharePoint MCP Server with Modern Azure AD Authentication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "msal>=1.24.0",
27
27
  "python-dotenv>=1.0.0",
28
28
  "pydantic>=2.0.0",
29
+ "requests>=2.31.0",
29
30
  ]
30
31
 
31
32
  [project.optional-dependencies]
@@ -19,6 +19,7 @@ from office365.sharepoint.folders.folder import Folder
19
19
  from office365.sharepoint.client_context import ClientContext
20
20
 
21
21
  from .auth import create_sharepoint_context
22
+ from .graph_api import GraphAPIClient
22
23
 
23
24
  # Setup logging
24
25
  logging.basicConfig(level=logging.INFO)
@@ -27,19 +28,70 @@ logger = logging.getLogger(__name__)
27
28
  # Initialize MCP server
28
29
  app = Server("mcp-sharepoint")
29
30
 
30
- # Global SharePoint context
31
+ # Global SharePoint context, Graph API client, and authenticator
31
32
  ctx: Optional[ClientContext] = None
33
+ graph_client: Optional[GraphAPIClient] = None
34
+ authenticator = None
32
35
 
33
36
 
34
37
  def ensure_context(func):
35
- """Decorator to ensure SharePoint context is available"""
38
+ """Decorator to ensure SharePoint context and Graph API client are available"""
36
39
  @wraps(func)
37
40
  async def wrapper(*args, **kwargs):
38
- global ctx
41
+ global ctx, graph_client, authenticator
39
42
  if ctx is None:
40
43
  try:
41
44
  ctx = create_sharepoint_context()
42
45
  logger.info("SharePoint context initialized successfully")
46
+
47
+ # Get site URL for Graph client
48
+ site_url = os.getenv("SHP_SITE_URL")
49
+
50
+ # Create Graph API client
51
+ # The token callback will use the same MSAL authenticator internally
52
+ def token_callback():
53
+ """Simple token callback that gets a fresh token using MSAL"""
54
+ from .auth import SharePointAuthenticator
55
+ from urllib.parse import urlparse
56
+
57
+ site_url = os.getenv("SHP_SITE_URL")
58
+ client_id = os.getenv("SHP_ID_APP")
59
+ client_secret = os.getenv("SHP_ID_APP_SECRET")
60
+ tenant_id = os.getenv("SHP_TENANT_ID")
61
+ cloud = "government" if ".sharepoint.us" in site_url else "commercial"
62
+
63
+ import msal
64
+ from office365.runtime.auth.token_response import TokenResponse
65
+
66
+ # Build authority URL
67
+ if cloud in ("government", "us"):
68
+ authority_url = f"https://login.microsoftonline.us/{tenant_id}"
69
+ else:
70
+ authority_url = f"https://login.microsoftonline.com/{tenant_id}"
71
+
72
+ # Create MSAL app
73
+ msal_app = msal.ConfidentialClientApplication(
74
+ authority=authority_url,
75
+ client_id=client_id,
76
+ client_credential=client_secret,
77
+ validate_authority=False if cloud in ("government", "us") else True
78
+ )
79
+
80
+ # Get scope
81
+ parsed = urlparse(site_url)
82
+ sharepoint_root = f"{parsed.scheme}://{parsed.netloc}"
83
+ scopes = [f"{sharepoint_root}/.default"]
84
+
85
+ # Acquire token
86
+ result = msal_app.acquire_token_for_client(scopes=scopes)
87
+ return TokenResponse.from_json(result)
88
+
89
+ graph_client = GraphAPIClient(
90
+ site_url=site_url,
91
+ token_callback=token_callback
92
+ )
93
+ logger.info("Graph API client initialized successfully")
94
+
43
95
  except Exception as e:
44
96
  logger.error(f"Failed to initialize SharePoint context: {e}")
45
97
  raise RuntimeError(
@@ -324,44 +376,85 @@ async def test_connection() -> list[TextContent]:
324
376
 
325
377
  async def list_folders(folder_path: str = "") -> list[TextContent]:
326
378
  """List folders in specified path"""
379
+ doc_lib = get_document_library_path()
380
+ full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
381
+
327
382
  try:
328
- doc_lib = get_document_library_path()
329
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
330
-
383
+ # Try SharePoint REST API first
331
384
  folder = ctx.web.get_folder_by_server_relative_path(full_path)
332
385
  folders = folder.folders.get().execute_query()
333
-
386
+
334
387
  folder_list = []
335
388
  for f in folders:
336
389
  folder_list.append(f"📁 {f.name}")
337
-
390
+
338
391
  result = f"Folders in '{full_path}':\n\n" + "\n".join(folder_list) if folder_list else f"No folders found in '{full_path}'"
339
-
340
392
  return [TextContent(type="text", text=result)]
341
-
393
+
342
394
  except Exception as e:
395
+ error_msg = str(e).lower()
396
+
397
+ # Check if it's an app-only token error
398
+ if "unsupported app only token" in error_msg or "401" in error_msg:
399
+ logger.warning(f"SharePoint REST API failed with app-only token error, falling back to Graph API")
400
+
401
+ try:
402
+ # Fallback to Graph API
403
+ folders = await asyncio.to_thread(graph_client.list_folders, folder_path)
404
+
405
+ folder_list = [f"📁 {f['name']}" for f in folders]
406
+
407
+ result = f"Folders in '{full_path}' (via Graph API):\n\n" + "\n".join(folder_list) if folder_list else f"No folders found in '{full_path}'"
408
+ return [TextContent(type="text", text=result)]
409
+
410
+ except Exception as graph_error:
411
+ return [TextContent(type="text", text=f"Error with both APIs - REST: {e}, Graph: {graph_error}")]
412
+
413
+ # Other errors
343
414
  return [TextContent(type="text", text=f"Error listing folders: {str(e)}")]
344
415
 
345
416
 
346
417
  async def list_documents(folder_path: str = "") -> list[TextContent]:
347
418
  """List documents in specified folder"""
419
+ doc_lib = get_document_library_path()
420
+ full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
421
+
348
422
  try:
349
- doc_lib = get_document_library_path()
350
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
351
-
423
+ # Try SharePoint REST API first
352
424
  folder = ctx.web.get_folder_by_server_relative_path(full_path)
353
425
  files = folder.files.get().execute_query()
354
-
426
+
355
427
  file_list = []
356
428
  for f in files:
357
429
  size_kb = f.length / 1024
358
430
  file_list.append(f"📄 {f.name} ({size_kb:.2f} KB)")
359
-
431
+
360
432
  result = f"Documents in '{full_path}':\n\n" + "\n".join(file_list) if file_list else f"No documents found in '{full_path}'"
361
-
362
433
  return [TextContent(type="text", text=result)]
363
-
434
+
364
435
  except Exception as e:
436
+ error_msg = str(e).lower()
437
+
438
+ # Check if it's an app-only token error
439
+ if "unsupported app only token" in error_msg or "401" in error_msg:
440
+ logger.warning(f"SharePoint REST API failed with app-only token error, falling back to Graph API")
441
+
442
+ try:
443
+ # Fallback to Graph API
444
+ files = await asyncio.to_thread(graph_client.list_documents, folder_path)
445
+
446
+ file_list = []
447
+ for f in files:
448
+ size_kb = f['size'] / 1024
449
+ file_list.append(f"📄 {f['name']} ({size_kb:.2f} KB)")
450
+
451
+ result = f"Documents in '{full_path}' (via Graph API):\n\n" + "\n".join(file_list) if file_list else f"No documents found in '{full_path}'"
452
+ return [TextContent(type="text", text=result)]
453
+
454
+ except Exception as graph_error:
455
+ return [TextContent(type="text", text=f"Error with both APIs - REST: {e}, Graph: {graph_error}")]
456
+
457
+ # Other errors
365
458
  return [TextContent(type="text", text=f"Error listing documents: {str(e)}")]
366
459
 
367
460
 
@@ -30,8 +30,9 @@ def _patch_datetime_bug():
30
30
  # Store the original __init__
31
31
  original_init = authentication_context.AuthenticationContext.__init__
32
32
 
33
- def patched_init(self, url):
34
- original_init(self, url)
33
+ def patched_init(self, *args, **kwargs):
34
+ # Call original init with all arguments
35
+ original_init(self, *args, **kwargs)
35
36
  # Make token_expires timezone-aware to prevent comparison errors
36
37
  if hasattr(self, '_token_expires') and self._token_expires is not None:
37
38
  if self._token_expires.tzinfo is None:
@@ -0,0 +1,289 @@
1
+ """
2
+ Microsoft Graph API implementation for SharePoint operations
3
+ Used as a fallback when SharePoint REST API doesn't support app-only tokens
4
+ """
5
+ import os
6
+ import logging
7
+ import asyncio
8
+ from typing import Optional, Dict, Any, List
9
+ from urllib.parse import urlparse, quote
10
+ import requests
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class GraphAPIClient:
16
+ """
17
+ Microsoft Graph API client for SharePoint operations.
18
+ Fallback for when SharePoint REST API doesn't support app-only authentication.
19
+ """
20
+
21
+ def __init__(self, site_url: str, token_callback):
22
+ """
23
+ Initialize Graph API client.
24
+
25
+ Args:
26
+ site_url: SharePoint site URL (e.g., https://tenant.sharepoint.us/sites/SiteName)
27
+ token_callback: Function that returns access token
28
+ """
29
+ self.site_url = site_url.rstrip("/")
30
+ self.token_callback = token_callback
31
+ self._site_id = None
32
+
33
+ # Determine Graph API endpoint based on cloud
34
+ if ".sharepoint.us" in site_url:
35
+ self.graph_endpoint = "https://graph.microsoft.us/v1.0"
36
+ logger.info("Using Microsoft Graph US Government endpoint")
37
+ else:
38
+ self.graph_endpoint = "https://graph.microsoft.com/v1.0"
39
+ logger.info("Using Microsoft Graph Commercial endpoint")
40
+
41
+ def _get_headers(self) -> Dict[str, str]:
42
+ """Get authorization headers with access token."""
43
+ token_obj = self.token_callback()
44
+ # Handle both TokenResponse objects and plain strings
45
+ if hasattr(token_obj, 'accessToken'):
46
+ token = token_obj.accessToken
47
+ else:
48
+ token = str(token_obj)
49
+
50
+ return {
51
+ "Authorization": f"Bearer {token}",
52
+ "Accept": "application/json",
53
+ }
54
+
55
+ def _get_site_id(self) -> str:
56
+ """
57
+ Get the site ID from the site URL.
58
+ Caches the result for reuse.
59
+ """
60
+ if self._site_id:
61
+ return self._site_id
62
+
63
+ parsed = urlparse(self.site_url)
64
+ hostname = parsed.netloc
65
+ path = parsed.path.strip("/")
66
+
67
+ # For root site: https://tenant.sharepoint.us
68
+ if not path or path == "sites":
69
+ url = f"{self.graph_endpoint}/sites/{hostname}"
70
+ # For subsite: https://tenant.sharepoint.us/sites/SiteName
71
+ else:
72
+ url = f"{self.graph_endpoint}/sites/{hostname}:/{path}"
73
+
74
+ response = requests.get(url, headers=self._get_headers())
75
+ response.raise_for_status()
76
+
77
+ self._site_id = response.json()["id"]
78
+ logger.info(f"Retrieved site ID: {self._site_id}")
79
+ return self._site_id
80
+
81
+ def _get_drive_id(self) -> str:
82
+ """Get the default document library drive ID."""
83
+ site_id = self._get_site_id()
84
+ url = f"{self.graph_endpoint}/sites/{site_id}/drive"
85
+
86
+ response = requests.get(url, headers=self._get_headers())
87
+ response.raise_for_status()
88
+
89
+ drive_id = response.json()["id"]
90
+ logger.info(f"Retrieved drive ID: {drive_id}")
91
+ return drive_id
92
+
93
+ def list_folders(self, folder_path: str = "") -> List[Dict[str, Any]]:
94
+ """
95
+ List folders in the specified path.
96
+
97
+ Args:
98
+ folder_path: Relative path from document library root
99
+
100
+ Returns:
101
+ List of folder objects with name, id, webUrl
102
+ """
103
+ site_id = self._get_site_id()
104
+ drive_id = self._get_drive_id()
105
+
106
+ if folder_path:
107
+ # URL encode the path
108
+ encoded_path = quote(folder_path)
109
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
110
+ else:
111
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
112
+
113
+ response = requests.get(url, headers=self._get_headers())
114
+ response.raise_for_status()
115
+
116
+ items = response.json().get("value", [])
117
+ # Filter to only folders
118
+ folders = [
119
+ {
120
+ "name": item["name"],
121
+ "id": item["id"],
122
+ "webUrl": item.get("webUrl", ""),
123
+ }
124
+ for item in items
125
+ if "folder" in item
126
+ ]
127
+
128
+ logger.info(f"Found {len(folders)} folders in '{folder_path}'")
129
+ return folders
130
+
131
+ def list_documents(self, folder_path: str = "") -> List[Dict[str, Any]]:
132
+ """
133
+ List documents in the specified folder.
134
+
135
+ Args:
136
+ folder_path: Relative path from document library root
137
+
138
+ Returns:
139
+ List of file objects with name, id, size, webUrl
140
+ """
141
+ site_id = self._get_site_id()
142
+ drive_id = self._get_drive_id()
143
+
144
+ if folder_path:
145
+ encoded_path = quote(folder_path)
146
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
147
+ else:
148
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
149
+
150
+ response = requests.get(url, headers=self._get_headers())
151
+ response.raise_for_status()
152
+
153
+ items = response.json().get("value", [])
154
+ # Filter to only files
155
+ files = [
156
+ {
157
+ "name": item["name"],
158
+ "id": item["id"],
159
+ "size": item.get("size", 0),
160
+ "webUrl": item.get("webUrl", ""),
161
+ }
162
+ for item in items
163
+ if "file" in item
164
+ ]
165
+
166
+ logger.info(f"Found {len(files)} files in '{folder_path}'")
167
+ return files
168
+
169
+ def get_file_content(self, file_path: str) -> bytes:
170
+ """
171
+ Get the content of a file.
172
+
173
+ Args:
174
+ file_path: Relative path to the file
175
+
176
+ Returns:
177
+ File content as bytes
178
+ """
179
+ site_id = self._get_site_id()
180
+ drive_id = self._get_drive_id()
181
+
182
+ encoded_path = quote(file_path)
183
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/content"
184
+
185
+ response = requests.get(url, headers=self._get_headers())
186
+ response.raise_for_status()
187
+
188
+ logger.info(f"Retrieved content for '{file_path}' ({len(response.content)} bytes)")
189
+ return response.content
190
+
191
+ def upload_file(self, folder_path: str, file_name: str, content: bytes) -> Dict[str, Any]:
192
+ """
193
+ Upload a file to SharePoint.
194
+
195
+ Args:
196
+ folder_path: Destination folder path
197
+ file_name: Name of the file
198
+ content: File content as bytes
199
+
200
+ Returns:
201
+ File metadata
202
+ """
203
+ site_id = self._get_site_id()
204
+ drive_id = self._get_drive_id()
205
+
206
+ if folder_path:
207
+ full_path = f"{folder_path}/{file_name}"
208
+ else:
209
+ full_path = file_name
210
+
211
+ encoded_path = quote(full_path)
212
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/content"
213
+
214
+ headers = self._get_headers()
215
+ headers["Content-Type"] = "application/octet-stream"
216
+
217
+ response = requests.put(url, headers=headers, data=content)
218
+ response.raise_for_status()
219
+
220
+ logger.info(f"Uploaded '{file_name}' to '{folder_path}'")
221
+ return response.json()
222
+
223
+ def delete_file(self, file_path: str) -> None:
224
+ """
225
+ Delete a file.
226
+
227
+ Args:
228
+ file_path: Relative path to the file
229
+ """
230
+ site_id = self._get_site_id()
231
+ drive_id = self._get_drive_id()
232
+
233
+ encoded_path = quote(file_path)
234
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}"
235
+
236
+ response = requests.delete(url, headers=self._get_headers())
237
+ response.raise_for_status()
238
+
239
+ logger.info(f"Deleted '{file_path}'")
240
+
241
+ def create_folder(self, parent_path: str, folder_name: str) -> Dict[str, Any]:
242
+ """
243
+ Create a new folder.
244
+
245
+ Args:
246
+ parent_path: Path to parent folder
247
+ folder_name: Name of the new folder
248
+
249
+ Returns:
250
+ Folder metadata
251
+ """
252
+ site_id = self._get_site_id()
253
+ drive_id = self._get_drive_id()
254
+
255
+ if parent_path:
256
+ encoded_path = quote(parent_path)
257
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
258
+ else:
259
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
260
+
261
+ payload = {
262
+ "name": folder_name,
263
+ "folder": {},
264
+ "@microsoft.graph.conflictBehavior": "fail"
265
+ }
266
+
267
+ response = requests.post(url, headers=self._get_headers(), json=payload)
268
+ response.raise_for_status()
269
+
270
+ logger.info(f"Created folder '{folder_name}' in '{parent_path}'")
271
+ return response.json()
272
+
273
+ def delete_folder(self, folder_path: str) -> None:
274
+ """
275
+ Delete a folder.
276
+
277
+ Args:
278
+ folder_path: Relative path to the folder
279
+ """
280
+ site_id = self._get_site_id()
281
+ drive_id = self._get_drive_id()
282
+
283
+ encoded_path = quote(folder_path)
284
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}"
285
+
286
+ response = requests.delete(url, headers=self._get_headers())
287
+ response.raise_for_status()
288
+
289
+ logger.info(f"Deleted folder '{folder_path}'")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-sharepoint-us
3
- Version: 2.0.10
3
+ Version: 2.0.12
4
4
  Summary: SharePoint MCP Server with Modern Azure AD Authentication
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/mdev26/mcp-sharepoint-us
@@ -22,6 +22,7 @@ Requires-Dist: office365-rest-python-client>=2.5.0
22
22
  Requires-Dist: msal>=1.24.0
23
23
  Requires-Dist: python-dotenv>=1.0.0
24
24
  Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: requests>=2.31.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=7.0.0; extra == "dev"
27
28
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/mcp_sharepoint/__init__.py
5
5
  src/mcp_sharepoint/__main__.py
6
6
  src/mcp_sharepoint/auth.py
7
+ src/mcp_sharepoint/graph_api.py
7
8
  src/mcp_sharepoint_us.egg-info/PKG-INFO
8
9
  src/mcp_sharepoint_us.egg-info/SOURCES.txt
9
10
  src/mcp_sharepoint_us.egg-info/dependency_links.txt
@@ -3,6 +3,7 @@ office365-rest-python-client>=2.5.0
3
3
  msal>=1.24.0
4
4
  python-dotenv>=1.0.0
5
5
  pydantic>=2.0.0
6
+ requests>=2.31.0
6
7
 
7
8
  [dev]
8
9
  pytest>=7.0.0