mirascope 1.21.5__py3-none-any.whl → 1.22.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.
mirascope/llm/__init__.py CHANGED
@@ -4,6 +4,7 @@ from ._context import context
4
4
  from ._override import override
5
5
  from .call_response import CallResponse
6
6
  from .stream import Stream
7
+ from .tool import Tool
7
8
 
8
9
  __all__ = [
9
10
  "CallResponse",
@@ -11,6 +12,7 @@ __all__ = [
11
12
  "LocalProvider",
12
13
  "Provider",
13
14
  "Stream",
15
+ "Tool",
14
16
  "calculate_cost",
15
17
  "call",
16
18
  "context",
mirascope/mcp/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Mirascope Model Context Protocol (MCP) implementation."""
2
2
 
3
- from .server import MCPServer
4
- from .tools import MCPTool
3
+ from .client import MCPClient, sse_client, stdio_client
4
+ from .server import MCPServer # DEPRECATED
5
+ from .tools import MCPTool # DEPRECATED
5
6
 
6
- __all__ = ["MCPServer", "MCPTool"]
7
+ __all__ = ["MCPClient", "MCPServer", "MCPTool", "sse_client", "stdio_client"]
@@ -0,0 +1,277 @@
1
+ """MCP client utility functions."""
2
+
3
+ import base64
4
+ import string
5
+ from collections.abc import AsyncGenerator, Awaitable, Callable
6
+ from contextlib import asynccontextmanager
7
+ from typing import Any
8
+
9
+ from anyio import create_memory_object_stream, create_task_group
10
+ from anyio.streams.memory import MemoryObjectReceiveStream
11
+ from mcp.types import (
12
+ EmbeddedResource,
13
+ ImageContent,
14
+ JSONRPCMessage,
15
+ PromptMessage,
16
+ TextContent,
17
+ TextResourceContents,
18
+ )
19
+ from mcp.types import Tool as MCPTool
20
+ from pydantic import BaseModel, ConfigDict, Field, create_model
21
+
22
+ from ..core import BaseMessageParam, BaseTool
23
+ from ..core.base import AudioPart, DocumentPart, ImagePart
24
+
25
+
26
+ def create_tool_call(
27
+ name: str,
28
+ call_tool: Callable[[str, dict | None], Awaitable[Any]],
29
+ ) -> Callable[..., Awaitable[list[str | ImageContent | EmbeddedResource]]]:
30
+ """Create a tool call function for a Mirascope Tool.
31
+
32
+ Args:
33
+ name: The name of the tool
34
+ call_tool: The function to call the tool
35
+
36
+ Returns:
37
+ A function that can be used as the call method for a Tool
38
+ """
39
+
40
+ async def call(self: BaseTool) -> list[str | ImageContent | EmbeddedResource]:
41
+ result = await call_tool(name, self.args) # pyright: ignore [reportOptionalCall]
42
+ if result.isError:
43
+ raise RuntimeError(f"MCP Server returned error: {self._name()}")
44
+ return [
45
+ content.text if isinstance(content, TextContent) else content
46
+ for content in result.content
47
+ ]
48
+
49
+ return call
50
+
51
+
52
+ def convert_prompt_message_to_base_message_params(
53
+ prompt_message: PromptMessage,
54
+ ) -> BaseMessageParam:
55
+ """
56
+ Convert a single PromptMessage back into a BaseMessageParam.
57
+
58
+ Args:
59
+ prompt_message: A PromptMessage instance.
60
+
61
+ Returns:
62
+ A BaseMessageParam instance representing the given prompt_message.
63
+
64
+ Raises:
65
+ ValueError: If the role is invalid or if the content type is unsupported.
66
+ """
67
+
68
+ # Validate role
69
+ if prompt_message.role not in ["user", "assistant"]:
70
+ raise ValueError(f"invalid role: {prompt_message.role}")
71
+
72
+ content = prompt_message.content
73
+
74
+ # Handle TextContent
75
+ if isinstance(content, TextContent):
76
+ # If it's text, we can just return a single string
77
+ return BaseMessageParam(role=prompt_message.role, content=content.text)
78
+
79
+ # Handle ImageContent
80
+ elif isinstance(content, ImageContent):
81
+ decoded_image = base64.b64decode(content.data)
82
+
83
+ image_part = ImagePart(
84
+ type="image",
85
+ image=decoded_image,
86
+ media_type=content.mimeType,
87
+ detail=None, # detail not provided by PromptMessage
88
+ )
89
+ return BaseMessageParam(role=prompt_message.role, content=[image_part])
90
+
91
+ elif isinstance(content, EmbeddedResource):
92
+ resource = content.resource
93
+ if isinstance(resource, TextResourceContents):
94
+ # For text content embedded resources, just return the text
95
+ return BaseMessageParam(role=prompt_message.role, content=resource.text)
96
+ else:
97
+ mime_type = resource.mimeType
98
+ if not mime_type:
99
+ raise ValueError(
100
+ "BlobResourceContents has no mimeType, cannot determine content type."
101
+ )
102
+
103
+ decoded_data = base64.b64decode(resource.blob)
104
+
105
+ if mime_type.startswith("image/"):
106
+ # Treat as ImagePart
107
+ image_part = ImagePart(
108
+ type="image",
109
+ image=decoded_data,
110
+ media_type=mime_type,
111
+ detail=None,
112
+ )
113
+ return BaseMessageParam(role=prompt_message.role, content=[image_part])
114
+ elif mime_type == "application/pdf":
115
+ doc_part = DocumentPart(
116
+ type="document", media_type=mime_type, document=decoded_data
117
+ )
118
+ return BaseMessageParam(role=prompt_message.role, content=[doc_part])
119
+ elif mime_type.startswith("audio/"):
120
+ # Treat as AudioPart
121
+ audio_part = AudioPart(
122
+ type="audio", media_type=mime_type, audio=decoded_data
123
+ )
124
+ return BaseMessageParam(role=prompt_message.role, content=[audio_part])
125
+ else:
126
+ raise ValueError(f"Unsupported mime type: {mime_type}")
127
+
128
+ else:
129
+ raise ValueError(f"Unsupported content type: {type(content)}")
130
+
131
+
132
+ def snake_to_pascal(snake: str) -> str:
133
+ """Convert a snake_case string to PascalCase.
134
+
135
+ Args:
136
+ snake: A snake_case string
137
+
138
+ Returns:
139
+ The string converted to PascalCase
140
+ """
141
+ return string.capwords(snake.replace("_", " ")).replace(" ", "")
142
+
143
+
144
+ def build_object_type(
145
+ properties: dict[str, Any], required: list[str], name: str
146
+ ) -> type[BaseModel]:
147
+ """Build a Pydantic model from JSON Schema properties.
148
+
149
+ Args:
150
+ properties: JSON Schema properties dictionary
151
+ required: List of required property names
152
+ name: Name for the generated model
153
+
154
+ Returns:
155
+ A dynamically created Pydantic model class
156
+ """
157
+ fields = {}
158
+ for prop_name, prop_schema in properties.items():
159
+ class_name = snake_to_pascal(prop_name)
160
+ type_ = json_schema_to_python_type(prop_schema, class_name)
161
+ if prop_name in required:
162
+ fields[prop_name] = (
163
+ type_,
164
+ Field(..., description=prop_schema.get("description")),
165
+ )
166
+ else:
167
+ fields[prop_name] = (
168
+ type_ | None,
169
+ Field(None, description=prop_schema.get("description")),
170
+ )
171
+
172
+ return create_model(name, __config__=ConfigDict(extra="allow"), **fields)
173
+
174
+
175
+ def json_schema_to_python_type(schema: dict[str, Any], name: str = "Model") -> Any: # noqa: ANN401
176
+ """
177
+ Recursively convert a JSON Schema snippet into a Python type annotation or a dynamically generated pydantic model.
178
+
179
+ Args:
180
+ schema: JSON Schema to convert
181
+ name: Name for any generated models
182
+
183
+ Returns:
184
+ Corresponding Python type or Pydantic model
185
+ """
186
+ json_type = schema.get("type", "any")
187
+
188
+ if json_type == "string":
189
+ return str
190
+ elif json_type == "number":
191
+ return float
192
+ elif json_type == "integer":
193
+ return int
194
+ elif json_type == "boolean":
195
+ return bool
196
+ elif json_type == "array":
197
+ # Recursively determine the items type for arrays
198
+ items_schema = schema.get("items", {})
199
+ items_type = json_schema_to_python_type(items_schema)
200
+ return list[items_type]
201
+ elif json_type == "object":
202
+ # Recursively build a dynamic model for objects
203
+ properties = schema.get("properties", {})
204
+ required = schema.get("required", [])
205
+ return build_object_type(properties, required, name)
206
+ else:
207
+ # Default fallback
208
+ return Any
209
+
210
+
211
+ def create_tool_from_mcp_tool(tool: MCPTool) -> type[BaseTool]:
212
+ """
213
+ Create a `BaseTool` type definition from the given MCP Tool instance.
214
+
215
+ Args:
216
+ tool: MCP tool instance.
217
+
218
+ Returns:
219
+ A dynamically created `Tool` schema.
220
+ """
221
+ schema = tool.inputSchema
222
+ properties = schema.get("properties", {})
223
+ required_fields = schema.get("required", [])
224
+
225
+ fields = {}
226
+ for field_name, field_schema in properties.items():
227
+ field_type = json_schema_to_python_type(field_schema)
228
+
229
+ if field_name in required_fields:
230
+ default = Field(..., description=field_schema.get("description"))
231
+ annotation = field_type
232
+ else:
233
+ default = Field(None, description=field_schema.get("description"))
234
+ annotation = field_type | None
235
+
236
+ fields[field_name] = (annotation, default)
237
+
238
+ return create_model(snake_to_pascal(tool.name), __base__=BaseTool, **fields)
239
+
240
+
241
+ @asynccontextmanager
242
+ async def read_stream_exception_filer(
243
+ original_read: MemoryObjectReceiveStream[JSONRPCMessage | Exception],
244
+ exception_handler: Callable[[Exception], None] | None = None,
245
+ ) -> AsyncGenerator[MemoryObjectReceiveStream[JSONRPCMessage], None]:
246
+ """
247
+ Handle exceptions in the original stream.
248
+ Default handler is to ignore read stream exception for invalid JSON lines and log them.
249
+
250
+ Args:
251
+ original_read: The original stream to read from.
252
+ exception_handler: An optional handler for exception.
253
+
254
+ Yields:
255
+ A filtered stream with exceptions removed
256
+ """
257
+ read_stream_writer, filtered_read = create_memory_object_stream(0)
258
+
259
+ async def task() -> None:
260
+ try:
261
+ async with original_read:
262
+ async for msg in original_read:
263
+ if isinstance(msg, Exception):
264
+ if exception_handler:
265
+ exception_handler(msg)
266
+ continue
267
+ await read_stream_writer.send(msg)
268
+ finally:
269
+ await read_stream_writer.aclose()
270
+
271
+ async with create_task_group() as tg:
272
+ tg.start_soon(task)
273
+ try:
274
+ yield filtered_read
275
+ finally:
276
+ await filtered_read.aclose()
277
+ tg.cancel_scope.cancel()