mcp-sharepoint-us 2.0.11__tar.gz → 2.0.13__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.

Potentially problematic release.


This version of mcp-sharepoint-us might be problematic. Click here for more details.

@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-sharepoint-us
3
- Version: 2.0.11
4
- Summary: SharePoint MCP Server with Modern Azure AD Authentication
3
+ Version: 2.0.13
4
+ Summary: SharePoint MCP Server with Microsoft Graph API
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/mdev26/mcp-sharepoint-us
7
7
  Project-URL: Repository, 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,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mcp-sharepoint-us"
7
- version = "2.0.11"
8
- description = "SharePoint MCP Server with Modern Azure AD Authentication"
7
+ version = "2.0.13"
8
+ description = "SharePoint MCP Server with Microsoft Graph API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
@@ -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]
@@ -14,11 +14,7 @@ from mcp.types import Resource, Tool, TextContent, ImageContent, EmbeddedResourc
14
14
  from pydantic import AnyUrl
15
15
  import mcp.server.stdio
16
16
 
17
- from office365.sharepoint.files.file import File
18
- from office365.sharepoint.folders.folder import Folder
19
- from office365.sharepoint.client_context import ClientContext
20
-
21
- from .auth import create_sharepoint_context
17
+ from .graph_api import GraphAPIClient
22
18
 
23
19
  # Setup logging
24
20
  logging.basicConfig(level=logging.INFO)
@@ -27,27 +23,55 @@ logger = logging.getLogger(__name__)
27
23
  # Initialize MCP server
28
24
  app = Server("mcp-sharepoint")
29
25
 
30
- # Global SharePoint context
31
- ctx: Optional[ClientContext] = None
26
+ # Global Graph API client and authenticator
27
+ graph_client: Optional[GraphAPIClient] = None
28
+ authenticator = None
32
29
 
33
30
 
34
31
  def ensure_context(func):
35
- """Decorator to ensure SharePoint context is available"""
32
+ """Decorator to ensure Graph API client is available"""
36
33
  @wraps(func)
37
34
  async def wrapper(*args, **kwargs):
38
- global ctx
39
- if ctx is None:
35
+ global graph_client, authenticator
36
+ if graph_client is None:
40
37
  try:
41
- ctx = create_sharepoint_context()
42
- logger.info("SharePoint context initialized successfully")
38
+ from .auth import SharePointAuthenticator
39
+
40
+ # Get credentials
41
+ site_url = os.getenv("SHP_SITE_URL")
42
+ client_id = os.getenv("SHP_ID_APP")
43
+ client_secret = os.getenv("SHP_ID_APP_SECRET")
44
+ tenant_id = os.getenv("SHP_TENANT_ID")
45
+ cloud = "government" if ".sharepoint.us" in site_url else "commercial"
46
+
47
+ # Create shared authenticator
48
+ authenticator = SharePointAuthenticator(
49
+ site_url=site_url,
50
+ client_id=client_id,
51
+ client_secret=client_secret,
52
+ tenant_id=tenant_id,
53
+ cloud=cloud
54
+ )
55
+
56
+ # Create Graph API client with direct token access
57
+ def get_token():
58
+ """Get access token for Graph API"""
59
+ return authenticator.get_access_token()
60
+
61
+ graph_client = GraphAPIClient(
62
+ site_url=site_url,
63
+ token_callback=get_token
64
+ )
65
+ logger.info("Graph API client initialized successfully")
66
+
43
67
  except Exception as e:
44
- logger.error(f"Failed to initialize SharePoint context: {e}")
68
+ logger.error(f"Failed to initialize Graph API client: {e}")
45
69
  raise RuntimeError(
46
- f"SharePoint authentication failed: {e}. "
70
+ f"Graph API authentication failed: {e}. "
47
71
  "Please check your environment variables and ensure:\n"
48
72
  "1. SHP_TENANT_ID is set correctly\n"
49
- "2. Your Azure AD app has the correct API permissions\n"
50
- "3. If using a new tenant, make sure you're using modern auth (MSAL)"
73
+ "2. Your Azure AD app has Microsoft Graph API permissions\n"
74
+ "3. The app registration has 'Sites.Read.All' and 'Files.ReadWrite.All' permissions"
51
75
  )
52
76
  return await func(*args, **kwargs)
53
77
  return wrapper
@@ -323,60 +347,48 @@ async def test_connection() -> list[TextContent]:
323
347
 
324
348
 
325
349
  async def list_folders(folder_path: str = "") -> list[TextContent]:
326
- """List folders in specified path"""
350
+ """List folders in specified path using Microsoft Graph API"""
351
+ doc_lib = get_document_library_path()
352
+ full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
353
+
327
354
  try:
328
- doc_lib = get_document_library_path()
329
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
330
-
331
- folder = ctx.web.get_folder_by_server_relative_path(full_path)
332
- folders = folder.folders.get().execute_query()
333
-
334
- folder_list = []
335
- for f in folders:
336
- folder_list.append(f"📁 {f.name}")
337
-
355
+ # Use Graph API directly
356
+ folders = await asyncio.to_thread(graph_client.list_folders, folder_path)
357
+ folder_list = [f"📁 {f['name']}" for f in folders]
358
+
338
359
  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
360
  return [TextContent(type="text", text=result)]
341
-
361
+
342
362
  except Exception as e:
343
363
  return [TextContent(type="text", text=f"Error listing folders: {str(e)}")]
344
364
 
345
365
 
346
366
  async def list_documents(folder_path: str = "") -> list[TextContent]:
347
- """List documents in specified folder"""
367
+ """List documents in specified folder using Microsoft Graph API"""
368
+ doc_lib = get_document_library_path()
369
+ full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
370
+
348
371
  try:
349
- doc_lib = get_document_library_path()
350
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
351
-
352
- folder = ctx.web.get_folder_by_server_relative_path(full_path)
353
- files = folder.files.get().execute_query()
354
-
372
+ # Use Graph API directly
373
+ files = await asyncio.to_thread(graph_client.list_documents, folder_path)
374
+
355
375
  file_list = []
356
376
  for f in files:
357
- size_kb = f.length / 1024
358
- file_list.append(f"📄 {f.name} ({size_kb:.2f} KB)")
359
-
377
+ size_kb = f['size'] / 1024
378
+ file_list.append(f"📄 {f['name']} ({size_kb:.2f} KB)")
379
+
360
380
  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
381
  return [TextContent(type="text", text=result)]
363
-
382
+
364
383
  except Exception as e:
365
384
  return [TextContent(type="text", text=f"Error listing documents: {str(e)}")]
366
385
 
367
386
 
368
387
  async def get_document_content(file_path: str) -> list[TextContent]:
369
- """Get document content"""
388
+ """Get document content using Microsoft Graph API"""
370
389
  try:
371
- doc_lib = get_document_library_path()
372
- full_path = f"{doc_lib}/{file_path}"
373
-
374
- def _read_bytes():
375
- sp_file = ctx.web.get_file_by_server_relative_path(full_path)
376
- # IMPORTANT: execute the request
377
- return sp_file.read().execute_query()
378
-
379
- content = await asyncio.to_thread(_read_bytes)
390
+ # Use Graph API to get file content
391
+ content = await asyncio.to_thread(graph_client.get_file_content, file_path)
380
392
 
381
393
  ext = os.path.splitext(file_path)[1].lower()
382
394
  text_extensions = {'.txt', '.md', '.json', '.xml', '.html', '.csv', '.log'}
@@ -401,141 +413,127 @@ async def get_document_content(file_path: str) -> list[TextContent]:
401
413
 
402
414
 
403
415
  async def upload_document(folder_path: str, file_name: str, content: str, is_binary: bool = False) -> list[TextContent]:
404
- """Upload a document"""
416
+ """Upload a document using Microsoft Graph API"""
405
417
  try:
406
- doc_lib = get_document_library_path()
407
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
408
-
409
- folder = ctx.web.get_folder_by_server_relative_path(full_path)
410
-
411
418
  if is_binary:
412
419
  file_content = base64.b64decode(content)
413
420
  else:
414
421
  file_content = content.encode('utf-8')
415
-
416
- uploaded_file = folder.upload_file(file_name, file_content).execute_query()
417
-
422
+
423
+ # Use Graph API to upload file
424
+ result = await asyncio.to_thread(
425
+ graph_client.upload_file,
426
+ folder_path,
427
+ file_name,
428
+ file_content
429
+ )
430
+
418
431
  return [TextContent(
419
432
  type="text",
420
- text=f"✓ Successfully uploaded '{file_name}' to '{full_path}'"
433
+ text=f"✓ Successfully uploaded '{file_name}' to '{folder_path or 'root'}'"
421
434
  )]
422
-
435
+
423
436
  except Exception as e:
424
437
  return [TextContent(type="text", text=f"Error uploading document: {str(e)}")]
425
438
 
426
439
 
427
440
  async def update_document(file_path: str, content: str, is_binary: bool = False) -> list[TextContent]:
428
- """Update a document"""
441
+ """Update a document using Microsoft Graph API"""
429
442
  try:
430
- doc_lib = get_document_library_path()
431
- full_path = f"{doc_lib}/{file_path}"
432
-
433
443
  if is_binary:
434
444
  file_content = base64.b64decode(content)
435
445
  else:
436
446
  file_content = content.encode('utf-8')
437
-
438
- file = ctx.web.get_file_by_server_relative_path(full_path)
439
- file.write(file_content).execute_query()
440
-
447
+
448
+ # Split file_path into folder and filename
449
+ folder_path = os.path.dirname(file_path)
450
+ file_name = os.path.basename(file_path)
451
+
452
+ # Use Graph API to upload/update file (PUT overwrites)
453
+ await asyncio.to_thread(
454
+ graph_client.upload_file,
455
+ folder_path,
456
+ file_name,
457
+ file_content
458
+ )
459
+
441
460
  return [TextContent(
442
461
  type="text",
443
462
  text=f"✓ Successfully updated '{file_path}'"
444
463
  )]
445
-
464
+
446
465
  except Exception as e:
447
466
  return [TextContent(type="text", text=f"Error updating document: {str(e)}")]
448
467
 
449
468
 
450
469
  async def delete_document(file_path: str) -> list[TextContent]:
451
- """Delete a document"""
470
+ """Delete a document using Microsoft Graph API"""
452
471
  try:
453
- doc_lib = get_document_library_path()
454
- full_path = f"{doc_lib}/{file_path}"
455
-
456
- file = ctx.web.get_file_by_server_relative_path(full_path)
457
- file.delete_object().execute_query()
458
-
472
+ # Use Graph API to delete file
473
+ await asyncio.to_thread(graph_client.delete_file, file_path)
474
+
459
475
  return [TextContent(
460
476
  type="text",
461
477
  text=f"✓ Successfully deleted '{file_path}'"
462
478
  )]
463
-
479
+
464
480
  except Exception as e:
465
481
  return [TextContent(type="text", text=f"Error deleting document: {str(e)}")]
466
482
 
467
483
 
468
484
  async def create_folder(folder_path: str, folder_name: str) -> list[TextContent]:
469
- """Create a folder"""
485
+ """Create a folder using Microsoft Graph API"""
470
486
  try:
471
- doc_lib = get_document_library_path()
472
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
473
-
474
- parent_folder = ctx.web.get_folder_by_server_relative_path(full_path)
475
- new_folder = parent_folder.folders.add(folder_name).execute_query()
476
-
487
+ # Use Graph API to create folder
488
+ await asyncio.to_thread(
489
+ graph_client.create_folder,
490
+ folder_path,
491
+ folder_name
492
+ )
493
+
477
494
  return [TextContent(
478
495
  type="text",
479
- text=f"✓ Successfully created folder '{folder_name}' in '{full_path}'"
496
+ text=f"✓ Successfully created folder '{folder_name}' in '{folder_path or 'root'}'"
480
497
  )]
481
-
498
+
482
499
  except Exception as e:
483
500
  return [TextContent(type="text", text=f"Error creating folder: {str(e)}")]
484
501
 
485
502
 
486
503
  async def delete_folder(folder_path: str) -> list[TextContent]:
487
- """Delete a folder"""
504
+ """Delete a folder using Microsoft Graph API"""
488
505
  try:
489
- doc_lib = get_document_library_path()
490
- full_path = f"{doc_lib}/{folder_path}"
491
-
492
- folder = ctx.web.get_folder_by_server_relative_path(full_path)
493
- folder.delete_object().execute_query()
494
-
506
+ # Use Graph API to delete folder
507
+ await asyncio.to_thread(graph_client.delete_folder, folder_path)
508
+
495
509
  return [TextContent(
496
510
  type="text",
497
511
  text=f"✓ Successfully deleted folder '{folder_path}'"
498
512
  )]
499
-
513
+
500
514
  except Exception as e:
501
515
  return [TextContent(type="text", text=f"Error deleting folder: {str(e)}")]
502
516
 
503
517
 
504
518
  async def get_tree(folder_path: str = "", max_depth: int = 5, current_depth: int = 0) -> list[TextContent]:
505
- """Get folder tree structure"""
519
+ """Get folder tree structure using Microsoft Graph API"""
506
520
  if current_depth >= max_depth:
507
521
  return [TextContent(type="text", text="Max depth reached")]
508
522
 
509
523
  try:
510
- doc_lib = get_document_library_path()
511
- full_path = f"{doc_lib}/{folder_path}" if folder_path else doc_lib
512
-
513
- folder = ctx.web.get_folder_by_server_relative_path(full_path)
514
- folders = folder.folders.get().execute_query()
524
+ # Use Graph API to list folders
525
+ folders = await asyncio.to_thread(graph_client.list_folders, folder_path)
515
526
 
516
527
  indent = " " * current_depth
517
528
  tree_lines = [f"{indent}📁 {folder_path or 'Root'}"]
518
529
 
519
530
  for f in folders:
520
- sub_path = f"{folder_path}/{f.name}" if folder_path else f.name
531
+ sub_path = f"{folder_path}/{f['name']}" if folder_path else f['name']
521
532
  sub_tree = await get_tree(sub_path, max_depth, current_depth + 1)
522
533
  tree_lines.append(sub_tree[0].text)
523
534
 
524
535
  return [TextContent(type="text", text="\n".join(tree_lines))]
525
536
 
526
- except TypeError as e:
527
- if "can't compare offset-naive and offset-aware datetimes" in str(e):
528
- logger.error(
529
- f"DateTime comparison error occurred despite patch. "
530
- f"This may indicate a new code path in the library. Error: {e}"
531
- )
532
- return [TextContent(
533
- type="text",
534
- text=f"Encountered a datetime comparison issue. "
535
- f"A workaround patch is applied, but this specific code path may need attention.\n"
536
- f"Alternative: Use List_SharePoint_Folders for folder navigation."
537
- )]
538
- raise
539
537
  except Exception as e:
540
538
  return [TextContent(type="text", text=f"Error getting tree: {str(e)}")]
541
539
 
@@ -7,7 +7,6 @@ import logging
7
7
  import time
8
8
  import random
9
9
  from typing import Optional
10
- from urllib.parse import urlparse
11
10
  from datetime import datetime, timezone
12
11
  from office365.sharepoint.client_context import ClientContext
13
12
  from office365.runtime.auth.client_credential import ClientCredential
@@ -85,7 +84,17 @@ class SharePointAuthenticator:
85
84
  self.cert_path = cert_path
86
85
  self.cert_thumbprint = cert_thumbprint
87
86
  self.cloud = cloud.lower()
88
-
87
+
88
+ # Initialize token cache
89
+ self._access_token = None
90
+ self._access_token_exp = 0
91
+
92
+ # Set Graph API scope based on cloud environment
93
+ if self.cloud in ("government", "us"):
94
+ self._scopes = ["https://graph.microsoft.us/.default"]
95
+ else:
96
+ self._scopes = ["https://graph.microsoft.com/.default"]
97
+
89
98
  def get_context_with_msal(self) -> ClientContext:
90
99
  """
91
100
  Get ClientContext using MSAL for modern Azure AD authentication.
@@ -123,19 +132,7 @@ class SharePointAuthenticator:
123
132
  self._msal_app = msal.ConfidentialClientApplication(**msal_params)
124
133
  self._authority_url = authority_url
125
134
 
126
- # Small in-memory access-token cache (avoid repeated acquire calls)
127
- # MSAL caches too, but keeping the raw token avoids extra work in Office365 callbacks.
128
- if not hasattr(self, "_access_token"):
129
- self._access_token = None
130
- self._access_token_exp = 0
131
-
132
- # Extract root SharePoint URL for scope
133
- # For https://tenant.sharepoint.us/sites/SiteName -> https://tenant.sharepoint.us
134
- parsed = urlparse(self.site_url)
135
- sharepoint_root = f"{parsed.scheme}://{parsed.netloc}"
136
- scopes = [f"{sharepoint_root}/.default"]
137
-
138
- logger.info(f"Using SharePoint root scope: {sharepoint_root}/.default")
135
+ logger.info(f"Using Graph API scope: {self._scopes[0]}")
139
136
 
140
137
  def acquire_token():
141
138
  """
@@ -153,7 +150,7 @@ class SharePointAuthenticator:
153
150
  last_err = None
154
151
  for attempt in range(1, 6): # 5 attempts
155
152
  try:
156
- result = self._msal_app.acquire_token_for_client(scopes=scopes)
153
+ result = self._msal_app.acquire_token_for_client(scopes=self._scopes)
157
154
 
158
155
  if "access_token" not in result:
159
156
  error_desc = result.get("error_description", "Unknown error")
@@ -161,7 +158,7 @@ class SharePointAuthenticator:
161
158
  raise ValueError(
162
159
  f"Failed to acquire token: {error} - {error_desc}\n"
163
160
  f"Authority: {self._authority_url}\n"
164
- f"Scopes: {scopes}"
161
+ f"Scopes: {self._scopes}"
165
162
  )
166
163
 
167
164
  token = result["access_token"]
@@ -191,7 +188,83 @@ class SharePointAuthenticator:
191
188
  logger.info("Successfully authenticated using MSAL (Modern Azure AD)")
192
189
  return ctx
193
190
 
194
-
191
+ def get_access_token(self) -> str:
192
+ """
193
+ Get access token directly for use with Microsoft Graph API.
194
+ Uses the same retry logic as get_context_with_msal() but returns just the token string.
195
+
196
+ Returns:
197
+ Access token as string
198
+
199
+ Raises:
200
+ RuntimeError: If token acquisition fails after retries
201
+ """
202
+ # Initialize MSAL app if not already done
203
+ if not hasattr(self, "_msal_app"):
204
+ if self.cloud in ("government", "us"):
205
+ authority_url = f"https://login.microsoftonline.us/{self.tenant_id}"
206
+ logger.info("Using Azure US Government Cloud endpoints")
207
+ else:
208
+ authority_url = f"https://login.microsoftonline.com/{self.tenant_id}"
209
+ logger.info("Using Azure Commercial Cloud endpoints")
210
+
211
+ self._token_cache = msal.SerializableTokenCache()
212
+
213
+ msal_params = {
214
+ "authority": authority_url,
215
+ "client_id": self.client_id,
216
+ "client_credential": self.client_secret,
217
+ "token_cache": self._token_cache,
218
+ }
219
+
220
+ if self.cloud in ("government", "us"):
221
+ msal_params["validate_authority"] = False
222
+ logger.info("Disabled authority validation for government cloud")
223
+
224
+ self._msal_app = msal.ConfidentialClientApplication(**msal_params)
225
+ self._authority_url = authority_url
226
+
227
+ now = int(time.time())
228
+ if self._access_token and now < (self._access_token_exp - 60):
229
+ return self._access_token
230
+
231
+ last_err = None
232
+ for attempt in range(1, 6): # 5 attempts
233
+ try:
234
+ result = self._msal_app.acquire_token_for_client(scopes=self._scopes)
235
+
236
+ if "access_token" not in result:
237
+ error_desc = result.get("error_description", "Unknown error")
238
+ error = result.get("error", "Unknown")
239
+ raise ValueError(
240
+ f"Failed to acquire token: {error} - {error_desc}\n"
241
+ f"Authority: {self._authority_url}\n"
242
+ f"Scopes: {self._scopes}"
243
+ )
244
+
245
+ token = result["access_token"]
246
+
247
+ # MSAL returns expires_in (seconds) for client credential tokens
248
+ expires_in = int(result.get("expires_in", 3600))
249
+ self._access_token = token
250
+ self._access_token_exp = int(time.time()) + expires_in
251
+
252
+ logger.info(f"Successfully acquired Graph API token")
253
+ return token
254
+
255
+ except Exception as e:
256
+ last_err = e
257
+ # Exponential backoff with jitter
258
+ sleep_s = min(8.0, (2 ** (attempt - 1)) * 0.5) + random.random() * 0.25
259
+ logger.warning(
260
+ f"Token acquisition attempt {attempt}/5 failed: {e}. Retrying in {sleep_s:.2f}s"
261
+ )
262
+ time.sleep(sleep_s)
263
+
264
+ # If we get here, all retries failed
265
+ raise RuntimeError(f"Token acquisition failed after retries: {last_err}")
266
+
267
+
195
268
  def get_context_with_certificate(self) -> ClientContext:
196
269
  """
197
270
  Get ClientContext using certificate-based authentication.
@@ -0,0 +1,328 @@
1
+ """
2
+ Microsoft Graph API implementation for SharePoint operations.
3
+ Primary API for all SharePoint operations in Azure Government Cloud.
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
+ Primary client for all SharePoint operations, especially in Azure Government Cloud
19
+ where SharePoint REST API may not support app-only authentication.
20
+ """
21
+
22
+ def __init__(self, site_url: str, token_callback):
23
+ """
24
+ Initialize Graph API client.
25
+
26
+ Args:
27
+ site_url: SharePoint site URL (e.g., https://tenant.sharepoint.us/sites/SiteName)
28
+ token_callback: Function that returns access token
29
+ """
30
+ self.site_url = site_url.rstrip("/")
31
+ self.token_callback = token_callback
32
+ self._site_id = None
33
+ self._drive_id = None # Cache drive ID to avoid repeated API calls
34
+
35
+ # Determine Graph API endpoint based on cloud
36
+ if ".sharepoint.us" in site_url:
37
+ self.graph_endpoint = "https://graph.microsoft.us/v1.0"
38
+ logger.info("Using Microsoft Graph US Government endpoint")
39
+ else:
40
+ self.graph_endpoint = "https://graph.microsoft.com/v1.0"
41
+ logger.info("Using Microsoft Graph Commercial endpoint")
42
+
43
+ def _get_headers(self) -> Dict[str, str]:
44
+ """Get authorization headers with access token."""
45
+ token_obj = self.token_callback()
46
+ # Handle both TokenResponse objects and plain strings
47
+ if hasattr(token_obj, 'accessToken'):
48
+ token = token_obj.accessToken
49
+ else:
50
+ token = str(token_obj)
51
+
52
+ return {
53
+ "Authorization": f"Bearer {token}",
54
+ "Accept": "application/json",
55
+ }
56
+
57
+ def _handle_response(self, response: requests.Response) -> None:
58
+ """
59
+ Handle Graph API response and raise detailed errors if needed.
60
+
61
+ Graph API returns errors in format:
62
+ {
63
+ "error": {
64
+ "code": "itemNotFound",
65
+ "message": "The resource could not be found."
66
+ }
67
+ }
68
+ """
69
+ if response.ok:
70
+ return
71
+
72
+ try:
73
+ error_data = response.json()
74
+ if "error" in error_data:
75
+ error = error_data["error"]
76
+ code = error.get("code", "Unknown")
77
+ message = error.get("message", "Unknown error")
78
+ raise requests.HTTPError(
79
+ f"Graph API error [{code}]: {message}",
80
+ response=response
81
+ )
82
+ except (ValueError, KeyError):
83
+ # If we can't parse the error, fall back to standard handling
84
+ pass
85
+
86
+ self._handle_response(response)
87
+
88
+ def _get_site_id(self) -> str:
89
+ """
90
+ Get the site ID from the site URL.
91
+ Caches the result for reuse.
92
+ """
93
+ if self._site_id:
94
+ return self._site_id
95
+
96
+ parsed = urlparse(self.site_url)
97
+ hostname = parsed.netloc
98
+ path = parsed.path.strip("/")
99
+
100
+ # For root site: https://tenant.sharepoint.us
101
+ if not path or path == "sites":
102
+ url = f"{self.graph_endpoint}/sites/{hostname}"
103
+ # For subsite: https://tenant.sharepoint.us/sites/SiteName
104
+ else:
105
+ url = f"{self.graph_endpoint}/sites/{hostname}:/{path}"
106
+
107
+ response = requests.get(url, headers=self._get_headers())
108
+ self._handle_response(response)
109
+
110
+ self._site_id = response.json()["id"]
111
+ logger.info(f"Retrieved site ID: {self._site_id}")
112
+ return self._site_id
113
+
114
+ def _get_drive_id(self) -> str:
115
+ """
116
+ Get the default document library drive ID.
117
+ Caches the result for reuse.
118
+ """
119
+ if self._drive_id:
120
+ return self._drive_id
121
+
122
+ site_id = self._get_site_id()
123
+ url = f"{self.graph_endpoint}/sites/{site_id}/drive"
124
+
125
+ response = requests.get(url, headers=self._get_headers())
126
+ self._handle_response(response)
127
+
128
+ self._drive_id = response.json()["id"]
129
+ logger.info(f"Retrieved drive ID: {self._drive_id}")
130
+ return self._drive_id
131
+
132
+ def list_folders(self, folder_path: str = "") -> List[Dict[str, Any]]:
133
+ """
134
+ List folders in the specified path.
135
+
136
+ Args:
137
+ folder_path: Relative path from document library root
138
+
139
+ Returns:
140
+ List of folder objects with name, id, webUrl
141
+ """
142
+ site_id = self._get_site_id()
143
+ drive_id = self._get_drive_id()
144
+
145
+ if folder_path:
146
+ # URL encode the path
147
+ encoded_path = quote(folder_path)
148
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
149
+ else:
150
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
151
+
152
+ response = requests.get(url, headers=self._get_headers())
153
+ self._handle_response(response)
154
+
155
+ items = response.json().get("value", [])
156
+ # Filter to only folders
157
+ folders = [
158
+ {
159
+ "name": item["name"],
160
+ "id": item["id"],
161
+ "webUrl": item.get("webUrl", ""),
162
+ }
163
+ for item in items
164
+ if "folder" in item
165
+ ]
166
+
167
+ logger.info(f"Found {len(folders)} folders in '{folder_path}'")
168
+ return folders
169
+
170
+ def list_documents(self, folder_path: str = "") -> List[Dict[str, Any]]:
171
+ """
172
+ List documents in the specified folder.
173
+
174
+ Args:
175
+ folder_path: Relative path from document library root
176
+
177
+ Returns:
178
+ List of file objects with name, id, size, webUrl
179
+ """
180
+ site_id = self._get_site_id()
181
+ drive_id = self._get_drive_id()
182
+
183
+ if folder_path:
184
+ encoded_path = quote(folder_path)
185
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
186
+ else:
187
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
188
+
189
+ response = requests.get(url, headers=self._get_headers())
190
+ self._handle_response(response)
191
+
192
+ items = response.json().get("value", [])
193
+ # Filter to only files
194
+ files = [
195
+ {
196
+ "name": item["name"],
197
+ "id": item["id"],
198
+ "size": item.get("size", 0),
199
+ "webUrl": item.get("webUrl", ""),
200
+ }
201
+ for item in items
202
+ if "file" in item
203
+ ]
204
+
205
+ logger.info(f"Found {len(files)} files in '{folder_path}'")
206
+ return files
207
+
208
+ def get_file_content(self, file_path: str) -> bytes:
209
+ """
210
+ Get the content of a file.
211
+
212
+ Args:
213
+ file_path: Relative path to the file
214
+
215
+ Returns:
216
+ File content as bytes
217
+ """
218
+ site_id = self._get_site_id()
219
+ drive_id = self._get_drive_id()
220
+
221
+ encoded_path = quote(file_path)
222
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/content"
223
+
224
+ response = requests.get(url, headers=self._get_headers())
225
+ self._handle_response(response)
226
+
227
+ logger.info(f"Retrieved content for '{file_path}' ({len(response.content)} bytes)")
228
+ return response.content
229
+
230
+ def upload_file(self, folder_path: str, file_name: str, content: bytes) -> Dict[str, Any]:
231
+ """
232
+ Upload a file to SharePoint.
233
+
234
+ Args:
235
+ folder_path: Destination folder path
236
+ file_name: Name of the file
237
+ content: File content as bytes
238
+
239
+ Returns:
240
+ File metadata
241
+ """
242
+ site_id = self._get_site_id()
243
+ drive_id = self._get_drive_id()
244
+
245
+ if folder_path:
246
+ full_path = f"{folder_path}/{file_name}"
247
+ else:
248
+ full_path = file_name
249
+
250
+ encoded_path = quote(full_path)
251
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/content"
252
+
253
+ headers = self._get_headers()
254
+ headers["Content-Type"] = "application/octet-stream"
255
+
256
+ response = requests.put(url, headers=headers, data=content)
257
+ self._handle_response(response)
258
+
259
+ logger.info(f"Uploaded '{file_name}' to '{folder_path}'")
260
+ return response.json()
261
+
262
+ def delete_file(self, file_path: str) -> None:
263
+ """
264
+ Delete a file.
265
+
266
+ Args:
267
+ file_path: Relative path to the file
268
+ """
269
+ site_id = self._get_site_id()
270
+ drive_id = self._get_drive_id()
271
+
272
+ encoded_path = quote(file_path)
273
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}"
274
+
275
+ response = requests.delete(url, headers=self._get_headers())
276
+ self._handle_response(response)
277
+
278
+ logger.info(f"Deleted '{file_path}'")
279
+
280
+ def create_folder(self, parent_path: str, folder_name: str) -> Dict[str, Any]:
281
+ """
282
+ Create a new folder.
283
+
284
+ Args:
285
+ parent_path: Path to parent folder
286
+ folder_name: Name of the new folder
287
+
288
+ Returns:
289
+ Folder metadata
290
+ """
291
+ site_id = self._get_site_id()
292
+ drive_id = self._get_drive_id()
293
+
294
+ if parent_path:
295
+ encoded_path = quote(parent_path)
296
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}:/children"
297
+ else:
298
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root/children"
299
+
300
+ payload = {
301
+ "name": folder_name,
302
+ "folder": {},
303
+ "@microsoft.graph.conflictBehavior": "fail"
304
+ }
305
+
306
+ response = requests.post(url, headers=self._get_headers(), json=payload)
307
+ self._handle_response(response)
308
+
309
+ logger.info(f"Created folder '{folder_name}' in '{parent_path}'")
310
+ return response.json()
311
+
312
+ def delete_folder(self, folder_path: str) -> None:
313
+ """
314
+ Delete a folder.
315
+
316
+ Args:
317
+ folder_path: Relative path to the folder
318
+ """
319
+ site_id = self._get_site_id()
320
+ drive_id = self._get_drive_id()
321
+
322
+ encoded_path = quote(folder_path)
323
+ url = f"{self.graph_endpoint}/sites/{site_id}/drives/{drive_id}/root:/{encoded_path}"
324
+
325
+ response = requests.delete(url, headers=self._get_headers())
326
+ self._handle_response(response)
327
+
328
+ logger.info(f"Deleted folder '{folder_path}'")
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-sharepoint-us
3
- Version: 2.0.11
4
- Summary: SharePoint MCP Server with Modern Azure AD Authentication
3
+ Version: 2.0.13
4
+ Summary: SharePoint MCP Server with Microsoft Graph API
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/mdev26/mcp-sharepoint-us
7
7
  Project-URL: Repository, 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