datarobot-genai 0.2.37__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.
Files changed (46) hide show
  1. datarobot_genai/core/agents/__init__.py +1 -1
  2. datarobot_genai/core/agents/base.py +5 -2
  3. datarobot_genai/core/chat/responses.py +6 -1
  4. datarobot_genai/core/utils/auth.py +188 -31
  5. datarobot_genai/crewai/__init__.py +1 -4
  6. datarobot_genai/crewai/agent.py +150 -17
  7. datarobot_genai/crewai/events.py +11 -4
  8. datarobot_genai/drmcp/__init__.py +4 -2
  9. datarobot_genai/drmcp/core/config.py +21 -1
  10. datarobot_genai/drmcp/core/mcp_instance.py +5 -49
  11. datarobot_genai/drmcp/core/routes.py +108 -13
  12. datarobot_genai/drmcp/core/tool_config.py +16 -0
  13. datarobot_genai/drmcp/core/utils.py +110 -0
  14. datarobot_genai/drmcp/test_utils/tool_base_ete.py +41 -26
  15. datarobot_genai/drmcp/tools/clients/gdrive.py +2 -0
  16. datarobot_genai/drmcp/tools/clients/microsoft_graph.py +141 -0
  17. datarobot_genai/drmcp/tools/clients/perplexity.py +173 -0
  18. datarobot_genai/drmcp/tools/clients/tavily.py +199 -0
  19. datarobot_genai/drmcp/tools/confluence/tools.py +43 -94
  20. datarobot_genai/drmcp/tools/gdrive/tools.py +44 -133
  21. datarobot_genai/drmcp/tools/jira/tools.py +19 -41
  22. datarobot_genai/drmcp/tools/microsoft_graph/tools.py +201 -32
  23. datarobot_genai/drmcp/tools/perplexity/__init__.py +0 -0
  24. datarobot_genai/drmcp/tools/perplexity/tools.py +117 -0
  25. datarobot_genai/drmcp/tools/predictive/data.py +1 -9
  26. datarobot_genai/drmcp/tools/predictive/deployment.py +0 -8
  27. datarobot_genai/drmcp/tools/predictive/deployment_info.py +91 -117
  28. datarobot_genai/drmcp/tools/predictive/model.py +0 -21
  29. datarobot_genai/drmcp/tools/predictive/predict_realtime.py +3 -0
  30. datarobot_genai/drmcp/tools/predictive/project.py +3 -19
  31. datarobot_genai/drmcp/tools/predictive/training.py +1 -19
  32. datarobot_genai/drmcp/tools/tavily/__init__.py +13 -0
  33. datarobot_genai/drmcp/tools/tavily/tools.py +141 -0
  34. datarobot_genai/langgraph/agent.py +10 -2
  35. datarobot_genai/llama_index/__init__.py +1 -1
  36. datarobot_genai/llama_index/agent.py +284 -5
  37. datarobot_genai/nat/agent.py +17 -6
  38. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/METADATA +3 -1
  39. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/RECORD +43 -40
  40. datarobot_genai/crewai/base.py +0 -159
  41. datarobot_genai/drmcp/core/tool_filter.py +0 -117
  42. datarobot_genai/llama_index/base.py +0 -299
  43. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/WHEEL +0 -0
  44. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/entry_points.txt +0 -0
  45. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/licenses/AUTHORS +0 -0
  46. {datarobot_genai-0.2.37.dist-info → datarobot_genai-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -25,7 +25,6 @@ from fastmcp.prompts.prompt import Prompt
25
25
  from fastmcp.server.dependencies import get_context
26
26
  from fastmcp.tools import Tool
27
27
  from mcp.types import AnyFunction
28
- from mcp.types import Tool as MCPTool
29
28
  from mcp.types import ToolAnnotations
30
29
  from typing_extensions import Unpack
31
30
 
@@ -36,8 +35,6 @@ from .logging import log_execution
36
35
  from .memory_management.manager import MemoryManager
37
36
  from .memory_management.manager import get_memory_manager
38
37
  from .telemetry import trace_execution
39
- from .tool_filter import filter_tools_by_tags
40
- from .tool_filter import list_all_tags
41
38
 
42
39
  logger = logging.getLogger(__name__)
43
40
 
@@ -80,10 +77,8 @@ async def get_agent_and_storage_ids(
80
77
  return agent_id, storage_id
81
78
 
82
79
 
83
- class TaggedFastMCP(FastMCP):
84
- """Extended FastMCP that supports tags, deployments and other annotations directly in the
85
- tool decorator.
86
- """
80
+ class DataRobotMCP(FastMCP):
81
+ """Extended FastMCP that supports DataRobot specific features like deployments and prompts."""
87
82
 
88
83
  def __init__(self, *args: Any, **kwargs: Any) -> None:
89
84
  super().__init__(*args, **kwargs)
@@ -118,45 +113,6 @@ class TaggedFastMCP(FastMCP):
118
113
  "In stateless mode, clients will see changes on next request."
119
114
  )
120
115
 
121
- async def list_tools(
122
- self, tags: list[str] | None = None, match_all: bool = False
123
- ) -> list[MCPTool]:
124
- """
125
- List all available tools, optionally filtered by tags.
126
-
127
- Args:
128
- tags: Optional list of tags to filter by. If None, returns all tools.
129
- match_all: If True, tool must have all specified tags (AND logic).
130
- If False, tool must have at least one tag (OR logic).
131
- Only used when tags is provided.
132
-
133
- Returns
134
- -------
135
- List of MCPTool objects that match the tag criteria.
136
- """
137
- # Get all tools from the parent class
138
- all_tools = await super()._list_tools_mcp()
139
-
140
- # If no tags specified, return all tools
141
- if not tags:
142
- return all_tools
143
-
144
- # Filter tools by tags
145
- filtered_tools = filter_tools_by_tags(list(all_tools), tags, match_all)
146
-
147
- return filtered_tools # type: ignore[return-value]
148
-
149
- async def get_all_tags(self) -> list[str]:
150
- """
151
- Get all unique tags from all registered tools.
152
-
153
- Returns
154
- -------
155
- List of all unique tags sorted alphabetically.
156
- """
157
- all_tools = await self._list_tools_mcp()
158
- return list_all_tags(list(all_tools))
159
-
160
116
  async def get_deployment_mapping(self) -> dict[str, str]:
161
117
  """
162
118
  Get the list of deployment IDs for all registered dynamic tools.
@@ -279,10 +235,10 @@ class TaggedFastMCP(FastMCP):
279
235
  )
280
236
 
281
237
 
282
- # Create the tagged MCP instance
238
+ # Create the DataRobot MCP instance
283
239
  mcp_server_configs: MCPServerConfig = get_config()
284
240
 
285
- mcp = TaggedFastMCP(
241
+ mcp = DataRobotMCP(
286
242
  name=mcp_server_configs.mcp_server_name,
287
243
  on_duplicate_tools=mcp_server_configs.tool_registration_duplicate_behavior,
288
244
  on_duplicate_prompts=mcp_server_configs.prompt_registration_duplicate_behavior,
@@ -446,7 +402,7 @@ async def register_tools(
446
402
  await mcp.set_deployment_mapping(deployment_id, tool_name)
447
403
 
448
404
  # Verify tool is registered
449
- tools = await mcp.list_tools()
405
+ tools = await mcp._list_tools_mcp()
450
406
  if not any(tool.name == tool_name for tool in tools):
451
407
  raise RuntimeError(f"Tool {tool_name} was not registered successfully")
452
408
  logger.info(f"Registered tools: {len(tools)}")
@@ -18,20 +18,28 @@ from botocore.exceptions import ClientError
18
18
  from starlette.requests import Request
19
19
  from starlette.responses import JSONResponse
20
20
 
21
+ from datarobot_genai import __version__
22
+
23
+ from .config import get_config
21
24
  from .dynamic_prompts.controllers import delete_registered_prompt_template
22
25
  from .dynamic_prompts.controllers import refresh_registered_prompt_template
23
26
  from .dynamic_prompts.controllers import register_prompt_from_prompt_template_id_and_version
24
27
  from .dynamic_tools.deployment.controllers import delete_registered_tool_deployment
25
28
  from .dynamic_tools.deployment.controllers import get_registered_tool_deployments
26
29
  from .dynamic_tools.deployment.controllers import register_tool_for_deployment_id
27
- from .mcp_instance import TaggedFastMCP
30
+ from .mcp_instance import DataRobotMCP
28
31
  from .memory_management.manager import get_memory_manager
29
32
  from .routes_utils import prefix_mount_path
33
+ from .tool_config import TOOL_CONFIGS
34
+ from .tool_config import ToolType
35
+ from .utils import get_prompt_tags
36
+ from .utils import get_resource_tags
37
+ from .utils import get_tool_tags
30
38
 
31
39
  logger = getLogger(__name__)
32
40
 
33
41
 
34
- def register_routes(mcp: TaggedFastMCP) -> None:
42
+ def register_routes(mcp: DataRobotMCP) -> None:
35
43
  """Register all routes with the MCP server."""
36
44
 
37
45
  @mcp.custom_route(prefix_mount_path("/"), methods=["GET"])
@@ -44,26 +52,113 @@ def register_routes(mcp: TaggedFastMCP) -> None:
44
52
  },
45
53
  )
46
54
 
47
- # Custom endpoint to get all tags
48
- @mcp.custom_route(prefix_mount_path("/tags"), methods=["GET"])
49
- async def handle_tags(_: Request) -> JSONResponse:
55
+ @mcp.custom_route(prefix_mount_path("/metadata"), methods=["GET"])
56
+ async def get_metadata(_: Request) -> JSONResponse:
57
+ """Get metadata about tools, prompts, resources, and system configuration."""
50
58
  try:
51
- # TaggedFastMCP extends FastMCP with get_all_tags
52
- tags = await mcp.get_all_tags() # type: ignore[attr-defined]
59
+ # Get tools with tags
60
+ tools = await mcp._list_tools_mcp()
61
+ tools_metadata = [
62
+ {
63
+ "name": tool.name,
64
+ "tags": sorted(list(get_tool_tags(tool))),
65
+ }
66
+ for tool in tools
67
+ ]
68
+
69
+ # Get prompts with tags
70
+ prompts = await mcp._list_prompts_mcp()
71
+ prompts_metadata = [
72
+ {
73
+ "name": prompt.name,
74
+ "tags": sorted(list(get_prompt_tags(prompt))),
75
+ }
76
+ for prompt in prompts
77
+ ]
78
+
79
+ # Get resources with tags
80
+ resources = await mcp._list_resources_mcp()
81
+ resources_metadata = [
82
+ {
83
+ "name": resource.name,
84
+ "tags": sorted(list(get_resource_tags(resource))),
85
+ }
86
+ for resource in resources
87
+ ]
88
+
89
+ # Get safe configuration details
90
+ config = get_config()
91
+
92
+ # Build tool config status
93
+ tool_config_status = {}
94
+ for tool_type in ToolType:
95
+ tool_config = TOOL_CONFIGS[tool_type]
96
+ is_enabled = getattr(config.tool_config, tool_config["config_field_name"], False)
97
+ oauth_check_fn = tool_config["oauth_check"]
98
+ oauth_required = oauth_check_fn is not None
99
+ oauth_configured = None
100
+ if oauth_required and oauth_check_fn is not None:
101
+ oauth_configured = oauth_check_fn(config)
102
+
103
+ tool_config_status[tool_type.value] = {
104
+ "enabled": is_enabled,
105
+ "oauth_required": oauth_required,
106
+ "oauth_configured": oauth_configured,
107
+ }
108
+
109
+ # Safe config details (excluding sensitive information)
110
+ safe_config = {
111
+ "server": {
112
+ "name": config.mcp_server_name,
113
+ "port": config.mcp_server_port,
114
+ "log_level": config.mcp_server_log_level,
115
+ "app_log_level": config.app_log_level,
116
+ "mount_path": config.mount_path,
117
+ "drmcp_genai_version": __version__,
118
+ },
119
+ "features": {
120
+ "register_dynamic_tools_on_startup": (
121
+ config.mcp_server_register_dynamic_tools_on_startup
122
+ ),
123
+ "register_dynamic_prompts_on_startup": (
124
+ config.mcp_server_register_dynamic_prompts_on_startup
125
+ ),
126
+ "tool_registration_allow_empty_schema": (
127
+ config.tool_registration_allow_empty_schema
128
+ ),
129
+ "tool_registration_duplicate_behavior": (
130
+ config.tool_registration_duplicate_behavior
131
+ ),
132
+ "prompt_registration_duplicate_behavior": (
133
+ config.prompt_registration_duplicate_behavior
134
+ ),
135
+ },
136
+ "tool_config": tool_config_status,
137
+ }
138
+
53
139
  return JSONResponse(
54
140
  status_code=HTTPStatus.OK,
55
141
  content={
56
- "tags": tags,
57
- "count": len(tags),
58
- "message": "All available tags retrieved successfully",
142
+ "tools": {
143
+ "items": tools_metadata,
144
+ "count": len(tools_metadata),
145
+ },
146
+ "prompts": {
147
+ "items": prompts_metadata,
148
+ "count": len(prompts_metadata),
149
+ },
150
+ "resources": {
151
+ "items": resources_metadata,
152
+ "count": len(resources_metadata),
153
+ },
154
+ "config": safe_config,
59
155
  },
60
156
  )
61
157
  except Exception as e:
158
+ logger.exception("Failed to retrieve metadata")
62
159
  return JSONResponse(
63
160
  status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
64
- content={
65
- "error": f"Failed to retrieve tags: {str(e)}",
66
- },
161
+ content={"error": f"Failed to retrieve metadata: {str(e)}"},
67
162
  )
68
163
 
69
164
  memory_manager = get_memory_manager()
@@ -31,6 +31,8 @@ class ToolType(str, Enum):
31
31
  CONFLUENCE = "confluence"
32
32
  GDRIVE = "gdrive"
33
33
  MICROSOFT_GRAPH = "microsoft_graph"
34
+ PERPLEXITY = "perplexity"
35
+ TAVILY = "tavily"
34
36
 
35
37
 
36
38
  class ToolConfig(TypedDict):
@@ -80,6 +82,20 @@ TOOL_CONFIGS: dict[ToolType, ToolConfig] = {
80
82
  package_prefix="datarobot_genai.drmcp.tools.microsoft_graph",
81
83
  config_field_name="enable_microsoft_graph_tools",
82
84
  ),
85
+ ToolType.PERPLEXITY: ToolConfig(
86
+ name="perplexity",
87
+ oauth_check=None, # OAuth for Perplexity is not supported
88
+ directory="perplexity",
89
+ package_prefix="datarobot_genai.drmcp.tools.perplexity",
90
+ config_field_name="enable_perplexity_tools",
91
+ ),
92
+ ToolType.TAVILY: ToolConfig(
93
+ name="tavily",
94
+ oauth_check=None,
95
+ directory="tavily",
96
+ package_prefix="datarobot_genai.drmcp.tools.tavily",
97
+ config_field_name="enable_tavily_tools",
98
+ ),
83
99
  }
84
100
 
85
101
 
@@ -18,7 +18,11 @@ from urllib.parse import urlparse
18
18
 
19
19
  import boto3
20
20
  from fastmcp.resources import HttpResource
21
+ from fastmcp.tools import Tool
21
22
  from fastmcp.tools.tool import ToolResult
23
+ from mcp.types import Prompt as MCPPrompt
24
+ from mcp.types import Resource as MCPResource
25
+ from mcp.types import Tool as MCPTool
22
26
  from pydantic import BaseModel
23
27
 
24
28
  from .constants import MAX_INLINE_SIZE
@@ -136,3 +140,109 @@ def is_valid_url(url: str) -> bool:
136
140
  """Check if a URL is valid."""
137
141
  result = urlparse(url)
138
142
  return all([result.scheme, result.netloc])
143
+
144
+
145
+ def get_prompt_tags(prompt: MCPPrompt) -> set[str]:
146
+ """
147
+ Extract tags from a prompt.
148
+
149
+ Args:
150
+ prompt: MCP protocol Prompt
151
+
152
+ Returns
153
+ -------
154
+ Set of tag strings, empty set if no tags found
155
+ """
156
+ # MCPPrompt has tags in meta._fastmcp.tags (as a list)
157
+ if not (prompt.meta and isinstance(prompt.meta, dict)):
158
+ return set()
159
+
160
+ fastmcp_meta = prompt.meta.get("_fastmcp")
161
+ if not (fastmcp_meta and isinstance(fastmcp_meta, dict)):
162
+ return set()
163
+
164
+ tags = fastmcp_meta.get("tags")
165
+ return set(tags) if tags else set()
166
+
167
+
168
+ def get_resource_tags(resource: MCPResource) -> set[str]:
169
+ """
170
+ Extract tags from a resource.
171
+
172
+ Args:
173
+ resource: MCP protocol Resource
174
+
175
+ Returns
176
+ -------
177
+ Set of tag strings, empty set if no tags found
178
+ """
179
+ # MCPResource has tags in meta._fastmcp.tags (as a list)
180
+ if not (resource.meta and isinstance(resource.meta, dict)):
181
+ return set()
182
+
183
+ fastmcp_meta = resource.meta.get("_fastmcp")
184
+ if not (fastmcp_meta and isinstance(fastmcp_meta, dict)):
185
+ return set()
186
+
187
+ tags = fastmcp_meta.get("tags")
188
+ return set(tags) if tags else set()
189
+
190
+
191
+ def get_tool_tags(tool: Tool | MCPTool) -> set[str]:
192
+ """
193
+ Extract tags from a tool, handling both FastMCP Tool and MCP protocol Tool types.
194
+
195
+ Args:
196
+ tool: Either a FastMCP Tool or MCP protocol Tool
197
+
198
+ Returns
199
+ -------
200
+ Set of tag strings, empty set if no tags found
201
+ """
202
+ if isinstance(tool, Tool):
203
+ # FastMCP Tool has tags directly as a set
204
+ return getattr(tool, "tags", None) or set()
205
+
206
+ # MCPTool has tags in meta._fastmcp.tags (as a list)
207
+ if not (tool.meta and isinstance(tool.meta, dict)):
208
+ return set()
209
+
210
+ fastmcp_meta = tool.meta.get("_fastmcp")
211
+ if not (fastmcp_meta and isinstance(fastmcp_meta, dict)):
212
+ return set()
213
+
214
+ tags = fastmcp_meta.get("tags")
215
+ return set(tags) if tags else set()
216
+
217
+
218
+ def filter_tools_by_tags(
219
+ *,
220
+ tools: list[Tool | MCPTool],
221
+ tags: list[str] | None = None,
222
+ match_all: bool = False,
223
+ ) -> list[Tool | MCPTool]:
224
+ """
225
+ Filter tools by tags.
226
+
227
+ Args:
228
+ tools: List of tools to filter
229
+ tags: List of tags to filter by. If None, returns all tools
230
+ match_all: If True, tool must have all specified tags. If False, tool must have at least
231
+ one tag.
232
+
233
+ Returns
234
+ -------
235
+ List of tools that match the tag criteria
236
+ """
237
+ if not tags:
238
+ return tools
239
+
240
+ # Convert tags to set for O(1) lookup instead of O(n)
241
+ tags_set = set(tags)
242
+
243
+ return [
244
+ tool
245
+ for tool in tools
246
+ if (tool_tags := get_tool_tags(tool))
247
+ and (tags_set.issubset(tool_tags) if match_all else tags_set & tool_tags)
248
+ ]
@@ -39,6 +39,17 @@ class ETETestExpectations(BaseModel):
39
39
  SHOULD_NOT_BE_EMPTY = "SHOULD_NOT_BE_EMPTY"
40
40
 
41
41
 
42
+ def _extract_content_when_structured_empty(tool_result: str) -> dict[str, str] | None:
43
+ r"""When Content is present but Structured content is empty, return {"error": content}."""
44
+ if "Content: " not in tool_result:
45
+ return None
46
+ content_part = tool_result.split("Content: ", 1)[1]
47
+ if "\nStructured content: " in content_part:
48
+ content_part = content_part.split("\nStructured content: ", 1)[0]
49
+ content_part = content_part.strip()
50
+ return {"error": content_part} if content_part else None
51
+
52
+
42
53
  def _extract_structured_content(tool_result: str) -> Any:
43
54
  r"""
44
55
  Extract and parse structured content from tool result string.
@@ -49,7 +60,9 @@ def _extract_structured_content(tool_result: str) -> Any:
49
60
  Structured content can be:
50
61
  1. A JSON object with a "result" key: {"result": "..."} or {"result": "{...}"}
51
62
  2. A direct JSON object: {"key": "value", ...}
52
- 3. Empty or missing
63
+ 3. Empty or missing — when Content is present but Structured content is empty
64
+ (e.g. tool errors), returns {"error": content} so dict expectations can validate.
65
+ 4. None if neither valid structured content nor Content is available
53
66
 
54
67
  Args:
55
68
  tool_result: The tool result string
@@ -58,33 +71,35 @@ def _extract_structured_content(tool_result: str) -> Any:
58
71
  -------
59
72
  Parsed structured content, or None if not available
60
73
  """
61
- # Early returns for invalid inputs
62
- if not tool_result or "Structured content: " not in tool_result:
63
- return None
64
-
65
- structured_part = tool_result.split("Structured content: ", 1)[1].strip()
66
- # Parse JSON, return None on failure or empty structured_part
67
- if not structured_part:
68
- return None
69
- try:
70
- structured_data = json.loads(structured_part)
71
- except json.JSONDecodeError:
72
- return None
73
-
74
- # If structured data has a "result" key, extract and parse that
75
- if isinstance(structured_data, dict) and "result" in structured_data:
76
- result_value = structured_data["result"]
77
- # If result is a JSON string (starts with { or [), try to parse it
78
- if isinstance(result_value, str) and result_value.strip().startswith(("{", "[")):
74
+ result: Any = None
75
+ if not tool_result:
76
+ pass
77
+ elif "Structured content: " in tool_result:
78
+ structured_part = tool_result.split("Structured content: ", 1)[1].strip()
79
+ if not structured_part:
80
+ result = _extract_content_when_structured_empty(tool_result)
81
+ else:
79
82
  try:
80
- parsed_result = json.loads(result_value)
83
+ structured_data = json.loads(structured_part)
81
84
  except json.JSONDecodeError:
82
- parsed_result = result_value # Return string as-is if parsing fails
83
- return parsed_result
84
- return result_value # Return result value directly
85
-
86
- # If it's a direct JSON object (not wrapped in {"result": ...}), return it as-is
87
- return structured_data
85
+ pass
86
+ else:
87
+ if isinstance(structured_data, dict) and "result" in structured_data:
88
+ result_value = structured_data["result"]
89
+ if isinstance(result_value, str) and result_value.strip().startswith(
90
+ ("{", "[")
91
+ ):
92
+ try:
93
+ result = json.loads(result_value)
94
+ except json.JSONDecodeError:
95
+ result = result_value
96
+ else:
97
+ result = result_value
98
+ else:
99
+ result = structured_data
100
+ else:
101
+ result = _extract_content_when_structured_empty(tool_result)
102
+ return result
88
103
 
89
104
 
90
105
  def _check_dict_has_keys(
@@ -351,6 +351,8 @@ class GoogleDriveClient:
351
351
  "pageSize": page_size,
352
352
  "fields": DEFAULT_FIELDS,
353
353
  "orderBy": DEFAULT_ORDER,
354
+ "supportsAllDrives": "true",
355
+ "includeItemsFromAllDrives": "true",
354
356
  }
355
357
  if page_token:
356
358
  params["pageToken"] = page_token
@@ -16,6 +16,7 @@
16
16
 
17
17
  import logging
18
18
  from typing import Any
19
+ from typing import Literal
19
20
  from urllib.parse import quote
20
21
 
21
22
  import httpx
@@ -541,6 +542,146 @@ class MicrosoftGraphClient:
541
542
 
542
543
  return MicrosoftGraphError(error_msg)
543
544
 
545
+ async def share_item(
546
+ self,
547
+ file_id: str,
548
+ document_library_id: str,
549
+ recipient_emails: list[str],
550
+ role: Literal["read", "write"],
551
+ send_invitation: bool,
552
+ ) -> None:
553
+ """
554
+ Share sharepoint / ondrive item using Microsoft Graph API.
555
+ Under the hood all resources in sharepoint/onedrive
556
+ in MS Graph API are treated as 'driveItem'.
557
+
558
+ Args:
559
+ file_id: The ID of the file or folder to share.
560
+ document_library_id: The ID of the document library containing the item.
561
+ recipient_emails: A list of email addresses to invite.
562
+ role: The role to assign.
563
+ send_invitation: Flag determining if recipients should be notified
564
+
565
+ Returns
566
+ -------
567
+ None
568
+
569
+ Raises
570
+ ------
571
+ MicrosoftGraphError: If sharing fails
572
+ """
573
+ graph_url = f"{GRAPH_API_BASE}/drives/{document_library_id}/items/{file_id}/invite"
574
+
575
+ payload = {
576
+ "recipients": [{"email": email} for email in recipient_emails],
577
+ "requireSignIn": True,
578
+ "sendInvitation": send_invitation,
579
+ "roles": [role],
580
+ }
581
+
582
+ response = await self._client.post(url=graph_url, json=payload)
583
+
584
+ if response.status_code not in (200, 201):
585
+ raise MicrosoftGraphError(
586
+ f"Microsoft Graph API error {response.status_code}: {response.text}"
587
+ )
588
+
589
+ async def update_item_metadata(
590
+ self,
591
+ item_id: str,
592
+ fields_to_update: dict[str, Any],
593
+ site_id: str | None = None,
594
+ list_id: str | None = None,
595
+ drive_id: str | None = None,
596
+ ) -> dict[str, Any]:
597
+ """Update metadata on a SharePoint list item or OneDrive/SharePoint drive item.
598
+
599
+ For SharePoint list items: Updates custom column values via the fields endpoint.
600
+ For drive items: Updates properties like name and description.
601
+
602
+ Args:
603
+ item_id: The ID of the item to update.
604
+ fields_to_update: Key-value pairs of metadata fields to modify.
605
+ site_id: For SharePoint list items - the site ID.
606
+ list_id: For SharePoint list items - the list ID.
607
+ drive_id: For OneDrive/drive items - the drive ID.
608
+
609
+ Returns
610
+ -------
611
+ The API response containing updated item data.
612
+
613
+ Raises
614
+ ------
615
+ MicrosoftGraphError: If validation fails or the API request fails.
616
+ """
617
+ if not item_id or not item_id.strip():
618
+ raise MicrosoftGraphError("item_id cannot be empty")
619
+ if not fields_to_update:
620
+ raise MicrosoftGraphError("fields_to_update cannot be empty")
621
+
622
+ # Determine the endpoint based on provided parameters
623
+ has_sharepoint_context = site_id is not None and list_id is not None
624
+ has_drive_context = drive_id is not None
625
+
626
+ if has_sharepoint_context and has_drive_context:
627
+ raise MicrosoftGraphError(
628
+ "Cannot specify both SharePoint (site_id + list_id) and OneDrive "
629
+ "(document_library_id) context. Choose one."
630
+ )
631
+
632
+ if not has_sharepoint_context and not has_drive_context:
633
+ raise MicrosoftGraphError(
634
+ "Must specify either SharePoint context (site_id + list_id) or "
635
+ "OneDrive context (document_library_id)."
636
+ )
637
+
638
+ try:
639
+ if has_sharepoint_context:
640
+ # PATCH /sites/{site-id}/lists/{list-id}/items/{item-id}/fields
641
+ url = f"{GRAPH_API_BASE}/sites/{site_id}/lists/{list_id}/items/{item_id}/fields"
642
+ response = await self._client.patch(url, json=fields_to_update)
643
+ else:
644
+ # Drive item: PATCH /drives/{drive-id}/items/{item-id}
645
+ url = f"{GRAPH_API_BASE}/drives/{drive_id}/items/{item_id}"
646
+ response = await self._client.patch(url, json=fields_to_update)
647
+
648
+ response.raise_for_status()
649
+ return response.json()
650
+ except httpx.HTTPStatusError as e:
651
+ raise self._handle_update_metadata_error(e, item_id) from e
652
+
653
+ def _handle_update_metadata_error(
654
+ self,
655
+ error: httpx.HTTPStatusError,
656
+ item_id: str,
657
+ ) -> MicrosoftGraphError:
658
+ """Handle HTTP errors for metadata updates and return appropriate MicrosoftGraphError."""
659
+ status_code = error.response.status_code
660
+ error_msg = f"Failed to update metadata: HTTP {status_code}"
661
+
662
+ if status_code == 400:
663
+ try:
664
+ error_data = error.response.json()
665
+ api_message = error_data.get("error", {}).get("message", "Invalid request")
666
+ error_msg = f"Bad request updating metadata: {api_message}"
667
+ except Exception:
668
+ error_msg = "Bad request: invalid field names or values."
669
+ elif status_code == 401:
670
+ error_msg = "Authentication failed. Access token may be expired or invalid."
671
+ elif status_code == 403:
672
+ error_msg = (
673
+ f"Permission denied: you don't have permission to update item '{item_id}'. "
674
+ "Requires Sites.ReadWrite.All or Files.ReadWrite.All permission."
675
+ )
676
+ elif status_code == 404:
677
+ error_msg = f"Item '{item_id}' not found."
678
+ elif status_code == 409:
679
+ error_msg = f"Conflict: item '{item_id}' may have been modified concurrently."
680
+ elif status_code == 429:
681
+ error_msg = "Rate limit exceeded. Please try again later."
682
+
683
+ return MicrosoftGraphError(error_msg)
684
+
544
685
  async def __aenter__(self) -> "MicrosoftGraphClient":
545
686
  """Async context manager entry."""
546
687
  return self