d365fo-client 0.2.3__py3-none-any.whl → 0.3.0__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 (58) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
  15. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  16. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  17. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  18. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  19. d365fo_client/mcp/client_manager.py +16 -67
  20. d365fo_client/mcp/fastmcp_main.py +358 -0
  21. d365fo_client/mcp/fastmcp_server.py +598 -0
  22. d365fo_client/mcp/fastmcp_utils.py +431 -0
  23. d365fo_client/mcp/main.py +40 -13
  24. d365fo_client/mcp/mixins/__init__.py +24 -0
  25. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  26. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  27. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  28. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  29. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  30. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  31. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  32. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  33. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  34. d365fo_client/mcp/prompts/action_execution.py +1 -1
  35. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  36. d365fo_client/mcp/tools/crud_tools.py +3 -3
  37. d365fo_client/mcp/tools/sync_tools.py +1 -1
  38. d365fo_client/mcp/utilities/__init__.py +1 -0
  39. d365fo_client/mcp/utilities/auth.py +34 -0
  40. d365fo_client/mcp/utilities/logging.py +58 -0
  41. d365fo_client/mcp/utilities/types.py +426 -0
  42. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  43. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  44. d365fo_client/models.py +139 -139
  45. d365fo_client/output.py +2 -2
  46. d365fo_client/profile_manager.py +62 -27
  47. d365fo_client/profiles.py +118 -113
  48. d365fo_client/settings.py +355 -0
  49. d365fo_client/sync_models.py +85 -2
  50. d365fo_client/utils.py +2 -1
  51. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +1261 -810
  52. d365fo_client-0.3.0.dist-info/RECORD +84 -0
  53. d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
  54. d365fo_client-0.2.3.dist-info/RECORD +0 -56
  55. d365fo_client-0.2.3.dist-info/entry_points.txt +0 -3
  56. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
  57. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
  58. {d365fo_client-0.2.3.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,426 @@
1
+ """Common types used across FastMCP."""
2
+
3
+ import base64
4
+ import inspect
5
+ import mimetypes
6
+ import os
7
+ from collections.abc import Callable
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from types import EllipsisType, UnionType
11
+ from typing import (
12
+ Annotated,
13
+ Any,
14
+ Protocol,
15
+ TypeAlias,
16
+ Union,
17
+ get_args,
18
+ get_origin,
19
+ get_type_hints,
20
+ )
21
+
22
+ import mcp.types
23
+ from mcp.types import Annotations, ContentBlock, ModelPreferences, SamplingMessage
24
+ from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints
25
+ from typing_extensions import TypeVar
26
+
27
+ T = TypeVar("T", default=Any)
28
+
29
+ # sentinel values for optional arguments
30
+ NotSet = ...
31
+ NotSetT: TypeAlias = EllipsisType
32
+
33
+
34
+ def get_fn_name(fn: Callable[..., Any]) -> str:
35
+ return fn.__name__ # ty: ignore[unresolved-attribute]
36
+
37
+
38
+ class FastMCPBaseModel(BaseModel):
39
+ """Base model for FastMCP models."""
40
+
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+
44
+ @lru_cache(maxsize=5000)
45
+ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
46
+ """
47
+ TypeAdapters are heavy objects, and in an application context we'd typically
48
+ create them once in a global scope and reuse them as often as possible.
49
+ However, this isn't feasible for user-generated functions. Instead, we use a
50
+ cache to minimize the cost of creating them as much as possible.
51
+ """
52
+ # For functions, process annotations to handle forward references and convert
53
+ # Annotated[Type, "string"] to Annotated[Type, Field(description="string")]
54
+ if inspect.isfunction(cls) or inspect.ismethod(cls):
55
+ if hasattr(cls, "__annotations__") and cls.__annotations__:
56
+ try:
57
+ # Resolve forward references first
58
+ resolved_hints = get_type_hints(cls, include_extras=True)
59
+ except Exception:
60
+ # If forward reference resolution fails, use original annotations
61
+ resolved_hints = cls.__annotations__
62
+
63
+ # Process annotations to convert string descriptions to Fields
64
+ processed_hints = {}
65
+
66
+ for name, annotation in resolved_hints.items():
67
+ # Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")]
68
+ if (
69
+ get_origin(annotation) is Annotated
70
+ and len(get_args(annotation)) == 2
71
+ and isinstance(get_args(annotation)[1], str)
72
+ ):
73
+ base_type, description = get_args(annotation)
74
+ processed_hints[name] = Annotated[
75
+ base_type, Field(description=description)
76
+ ]
77
+ else:
78
+ processed_hints[name] = annotation
79
+
80
+ # Create new function if annotations changed
81
+ if processed_hints != cls.__annotations__:
82
+ import types
83
+
84
+ # Handle both functions and methods
85
+ if inspect.ismethod(cls):
86
+ actual_func = cls.__func__
87
+ code = actual_func.__code__ # ty: ignore[unresolved-attribute]
88
+ globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute]
89
+ name = actual_func.__name__ # ty: ignore[unresolved-attribute]
90
+ defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute]
91
+ closure = actual_func.__closure__ # ty: ignore[unresolved-attribute]
92
+ else:
93
+ code = cls.__code__
94
+ globals_dict = cls.__globals__
95
+ name = cls.__name__
96
+ defaults = cls.__defaults__
97
+ closure = cls.__closure__
98
+
99
+ new_func = types.FunctionType(
100
+ code,
101
+ globals_dict,
102
+ name,
103
+ defaults,
104
+ closure,
105
+ )
106
+ new_func.__dict__.update(cls.__dict__)
107
+ new_func.__module__ = cls.__module__
108
+ new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
109
+ new_func.__annotations__ = processed_hints
110
+
111
+ if inspect.ismethod(cls):
112
+ new_method = types.MethodType(new_func, cls.__self__)
113
+ return TypeAdapter(new_method)
114
+ else:
115
+ return TypeAdapter(new_func)
116
+
117
+ return TypeAdapter(cls)
118
+
119
+
120
+ def issubclass_safe(cls: type, base: type) -> bool:
121
+ """Check if cls is a subclass of base, even if cls is a type variable."""
122
+ try:
123
+ if origin := get_origin(cls):
124
+ return issubclass_safe(origin, base)
125
+ return issubclass(cls, base)
126
+ except TypeError:
127
+ return False
128
+
129
+
130
+ def is_class_member_of_type(cls: Any, base: type) -> bool:
131
+ """
132
+ Check if cls is a member of base, even if cls is a type variable.
133
+
134
+ Base can be a type, a UnionType, or an Annotated type. Generic types are not
135
+ considered members (e.g. T is not a member of list[T]).
136
+ """
137
+ origin = get_origin(cls)
138
+ # Handle both types of unions: UnionType (from types module, used with | syntax)
139
+ # and typing.Union (used with Union[] syntax)
140
+ if origin is UnionType or origin == Union:
141
+ return any(is_class_member_of_type(arg, base) for arg in get_args(cls))
142
+ elif origin is Annotated:
143
+ # For Annotated[T, ...], check if T is a member of base
144
+ args = get_args(cls)
145
+ if args:
146
+ return is_class_member_of_type(args[0], base)
147
+ return False
148
+ else:
149
+ return issubclass_safe(cls, base)
150
+
151
+
152
+ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
153
+ """
154
+ Find the name of the kwarg that is of type kwarg_type.
155
+
156
+ Includes union types that contain the kwarg_type, as well as Annotated types.
157
+ """
158
+ if inspect.ismethod(fn) and hasattr(fn, "__func__"):
159
+ fn = fn.__func__
160
+
161
+ # Try to get resolved type hints
162
+ try:
163
+ # Use include_extras=True to preserve Annotated metadata
164
+ type_hints = get_type_hints(fn, include_extras=True)
165
+ except Exception:
166
+ # If resolution fails, use raw annotations if they exist
167
+ type_hints = getattr(fn, "__annotations__", {})
168
+
169
+ sig = inspect.signature(fn)
170
+ for name, param in sig.parameters.items():
171
+ # Use resolved hint if available, otherwise raw annotation
172
+ annotation = type_hints.get(name, param.annotation)
173
+ if is_class_member_of_type(annotation, kwarg_type):
174
+ return name
175
+ return None
176
+
177
+
178
+ class Image:
179
+ """Helper class for returning images from tools."""
180
+
181
+ def __init__(
182
+ self,
183
+ path: str | Path | None = None,
184
+ data: bytes | None = None,
185
+ format: str | None = None,
186
+ annotations: Annotations | None = None,
187
+ ):
188
+ if path is None and data is None:
189
+ raise ValueError("Either path or data must be provided")
190
+ if path is not None and data is not None:
191
+ raise ValueError("Only one of path or data can be provided")
192
+
193
+ self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
194
+ self.data = data
195
+ self._format = format
196
+ self._mime_type = self._get_mime_type()
197
+ self.annotations = annotations
198
+
199
+ def _get_mime_type(self) -> str:
200
+ """Get MIME type from format or guess from file extension."""
201
+ if self._format:
202
+ return f"image/{self._format.lower()}"
203
+
204
+ if self.path:
205
+ suffix = self.path.suffix.lower()
206
+ return {
207
+ ".png": "image/png",
208
+ ".jpg": "image/jpeg",
209
+ ".jpeg": "image/jpeg",
210
+ ".gif": "image/gif",
211
+ ".webp": "image/webp",
212
+ }.get(suffix, "application/octet-stream")
213
+ return "image/png" # default for raw binary data
214
+
215
+ def to_image_content(
216
+ self,
217
+ mime_type: str | None = None,
218
+ annotations: Annotations | None = None,
219
+ ) -> mcp.types.ImageContent:
220
+ """Convert to MCP ImageContent."""
221
+ if self.path:
222
+ with open(self.path, "rb") as f:
223
+ data = base64.b64encode(f.read()).decode()
224
+ elif self.data is not None:
225
+ data = base64.b64encode(self.data).decode()
226
+ else:
227
+ raise ValueError("No image data available")
228
+
229
+ return mcp.types.ImageContent(
230
+ type="image",
231
+ data=data,
232
+ mimeType=mime_type or self._mime_type,
233
+ annotations=annotations or self.annotations,
234
+ )
235
+
236
+
237
+ class Audio:
238
+ """Helper class for returning audio from tools."""
239
+
240
+ def __init__(
241
+ self,
242
+ path: str | Path | None = None,
243
+ data: bytes | None = None,
244
+ format: str | None = None,
245
+ annotations: Annotations | None = None,
246
+ ):
247
+ if path is None and data is None:
248
+ raise ValueError("Either path or data must be provided")
249
+ if path is not None and data is not None:
250
+ raise ValueError("Only one of path or data can be provided")
251
+
252
+ self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
253
+ self.data = data
254
+ self._format = format
255
+ self._mime_type = self._get_mime_type()
256
+ self.annotations = annotations
257
+
258
+ def _get_mime_type(self) -> str:
259
+ """Get MIME type from format or guess from file extension."""
260
+ if self._format:
261
+ return f"audio/{self._format.lower()}"
262
+
263
+ if self.path:
264
+ suffix = self.path.suffix.lower()
265
+ return {
266
+ ".wav": "audio/wav",
267
+ ".mp3": "audio/mpeg",
268
+ ".ogg": "audio/ogg",
269
+ ".m4a": "audio/mp4",
270
+ ".flac": "audio/flac",
271
+ }.get(suffix, "application/octet-stream")
272
+ return "audio/wav" # default for raw binary data
273
+
274
+ def to_audio_content(
275
+ self,
276
+ mime_type: str | None = None,
277
+ annotations: Annotations | None = None,
278
+ ) -> mcp.types.AudioContent:
279
+ if self.path:
280
+ with open(self.path, "rb") as f:
281
+ data = base64.b64encode(f.read()).decode()
282
+ elif self.data is not None:
283
+ data = base64.b64encode(self.data).decode()
284
+ else:
285
+ raise ValueError("No audio data available")
286
+
287
+ return mcp.types.AudioContent(
288
+ type="audio",
289
+ data=data,
290
+ mimeType=mime_type or self._mime_type,
291
+ annotations=annotations or self.annotations,
292
+ )
293
+
294
+
295
+ class File:
296
+ """Helper class for returning audio from tools."""
297
+
298
+ def __init__(
299
+ self,
300
+ path: str | Path | None = None,
301
+ data: bytes | None = None,
302
+ format: str | None = None,
303
+ name: str | None = None,
304
+ annotations: Annotations | None = None,
305
+ ):
306
+ if path is None and data is None:
307
+ raise ValueError("Either path or data must be provided")
308
+ if path is not None and data is not None:
309
+ raise ValueError("Only one of path or data can be provided")
310
+
311
+ self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None
312
+ self.data = data
313
+ self._format = format
314
+ self._mime_type = self._get_mime_type()
315
+ self._name = name
316
+ self.annotations = annotations
317
+
318
+ def _get_mime_type(self) -> str:
319
+ """Get MIME type from format or guess from file extension."""
320
+ if self._format:
321
+ fmt = self._format.lower()
322
+ # Map common text formats to text/plain
323
+ if fmt in {"plain", "txt", "text"}:
324
+ return "text/plain"
325
+ return f"application/{fmt}"
326
+
327
+ if self.path:
328
+ mime_type, _ = mimetypes.guess_type(self.path)
329
+ if mime_type:
330
+ return mime_type
331
+
332
+ return "application/octet-stream"
333
+
334
+ def to_resource_content(
335
+ self,
336
+ mime_type: str | None = None,
337
+ annotations: Annotations | None = None,
338
+ ) -> mcp.types.EmbeddedResource:
339
+ if self.path:
340
+ with open(self.path, "rb") as f:
341
+ raw_data = f.read()
342
+ uri_str = self.path.resolve().as_uri()
343
+ elif self.data is not None:
344
+ raw_data = self.data
345
+ if self._name:
346
+ uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}"
347
+ else:
348
+ uri_str = f"file:///resource.{self._mime_type.split('/')[1]}"
349
+ else:
350
+ raise ValueError("No resource data available")
351
+
352
+ mime = mime_type or self._mime_type
353
+ UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)]
354
+ uri = TypeAdapter(UriType).validate_python(uri_str)
355
+
356
+ if mime.startswith("text/"):
357
+ try:
358
+ text = raw_data.decode("utf-8")
359
+ except UnicodeDecodeError:
360
+ text = raw_data.decode("latin-1")
361
+ resource = mcp.types.TextResourceContents(
362
+ text=text,
363
+ mimeType=mime,
364
+ uri=uri,
365
+ )
366
+ else:
367
+ data = base64.b64encode(raw_data).decode()
368
+ resource = mcp.types.BlobResourceContents(
369
+ blob=data,
370
+ mimeType=mime,
371
+ uri=uri,
372
+ )
373
+
374
+ return mcp.types.EmbeddedResource(
375
+ type="resource",
376
+ resource=resource,
377
+ annotations=annotations or self.annotations,
378
+ )
379
+
380
+
381
+ def replace_type(type_, type_map: dict[type, type]):
382
+ """
383
+ Given a (possibly generic, nested, or otherwise complex) type, replaces all
384
+ instances of old_type with new_type.
385
+
386
+ This is useful for transforming types when creating tools.
387
+
388
+ Args:
389
+ type_: The type to replace instances of old_type with new_type.
390
+ old_type: The type to replace.
391
+ new_type: The type to replace old_type with.
392
+
393
+ Examples:
394
+ ```python
395
+ >>> replace_type(list[int | bool], {int: str})
396
+ list[str | bool]
397
+
398
+ >>> replace_type(list[list[int]], {int: str})
399
+ list[list[str]]
400
+ ```
401
+ """
402
+ if type_ in type_map:
403
+ return type_map[type_]
404
+
405
+ origin = get_origin(type_)
406
+ if not origin:
407
+ return type_
408
+
409
+ args = get_args(type_)
410
+ new_args = tuple(replace_type(arg, type_map) for arg in args)
411
+
412
+ if origin is UnionType:
413
+ return Union[new_args] # type: ignore # noqa: UP007
414
+ else:
415
+ return origin[new_args]
416
+
417
+
418
+ class ContextSamplingFallbackProtocol(Protocol):
419
+ async def __call__(
420
+ self,
421
+ messages: str | list[str | SamplingMessage],
422
+ system_prompt: str | None = None,
423
+ temperature: float | None = None,
424
+ max_tokens: int | None = None,
425
+ model_preferences: ModelPreferences | str | list[str] | None = None,
426
+ ) -> ContentBlock: ...
@@ -17,6 +17,8 @@ from ..models import (
17
17
  LabelInfo,
18
18
  MetadataVersionInfo,
19
19
  PublicEntityInfo,
20
+ )
21
+ from ..sync_models import (
20
22
  SyncProgress,
21
23
  SyncResult,
22
24
  SyncStrategy,
@@ -14,15 +14,15 @@ from ..models import (
14
14
  EnumerationInfo,
15
15
  LabelInfo,
16
16
  PublicEntityInfo,
17
- SyncResult,
18
- SyncStrategy,
19
17
  )
20
18
  from ..sync_models import (
21
19
  SyncActivity,
22
20
  SyncPhase,
21
+ SyncResult,
23
22
  SyncSession,
24
23
  SyncSessionSummary,
25
24
  SyncStatus,
25
+ SyncStrategy,
26
26
  )
27
27
  from .cache_v2 import MetadataCacheV2
28
28
 
@@ -282,13 +282,13 @@ class SyncSessionManager:
282
282
  await self._complete_phase(session, SyncPhase.FINALIZING)
283
283
 
284
284
  return SyncResult(
285
+ sync_type="full",
285
286
  success=True,
286
- error=None,
287
287
  duration_ms=duration_ms,
288
- entity_count=entity_count,
289
- action_count=action_count,
290
- enumeration_count=enumeration_count,
291
- label_count=label_count
288
+ entities_synced=entity_count,
289
+ actions_synced=action_count,
290
+ enumerations_synced=enumeration_count,
291
+ labels_synced=label_count
292
292
  )
293
293
 
294
294
  async def _sync_entities_with_progress(self, session: SyncSession):